@seasonkoh/webaz 0.1.17 → 0.1.19

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.
@@ -0,0 +1,180 @@
1
+ import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
2
+ import { creditBuildReputation, BUILD_POINTS } from './build-reputation-engine.js';
3
+ export const TASK_STATUS = new Set(['open', 'claimed', 'in_review', 'done', 'abandoned']);
4
+ export const TASK_PROVENANCE = new Set(['human', 'ai_assisted', 'ai_authored']);
5
+ const CLAIM_TTL_DAYS = 7; // 认领后多久没进 in_review 自动回 open
6
+ const CREATE_RATE_PER_DAY = 10; // 每人每日建任务上限(反灌水)
7
+ const MAX_ACTIVE_CLAIMS = 5; // 单人同时持有的 claimed+in_review 上限(防"全占坑")
8
+ const TITLE_MAX = 200;
9
+ const TEXT_MAX = 4000;
10
+ export function initBuildTasksSchema(db) {
11
+ db.exec(`
12
+ CREATE TABLE IF NOT EXISTS build_tasks (
13
+ id TEXT PRIMARY KEY, -- bt_xxx
14
+ title TEXT NOT NULL,
15
+ area TEXT, -- search / docs / mcp / dispute / ...(自由建议枚举)
16
+ description TEXT,
17
+ rfc_ref TEXT, -- 关联 RFC(如 RFC-006)
18
+ status TEXT NOT NULL DEFAULT 'open',-- open | claimed | in_review | done | abandoned
19
+ claimer_id TEXT,
20
+ claimer_provenance TEXT, -- human | ai_assisted | ai_authored(自报)
21
+ pr_ref TEXT, -- submit 时填(PR 链接 / 编号)
22
+ claimed_at TEXT,
23
+ claim_expires_at TEXT, -- claimed 的 TTL,过期自动回 open
24
+ created_by TEXT NOT NULL,
25
+ resolution TEXT, -- admin 验收说明(done/abandoned)
26
+ resolved_by TEXT,
27
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
28
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ )
30
+ `);
31
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_build_tasks_status ON build_tasks(status, updated_at DESC)`);
32
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_build_tasks_area ON build_tasks(area, status)`);
33
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_build_tasks_claimer ON build_tasks(claimer_id, status)`);
34
+ // 状态变更审计(可追溯谁在何时改了什么)
35
+ db.exec(`
36
+ CREATE TABLE IF NOT EXISTS build_task_events (
37
+ id TEXT PRIMARY KEY, -- btev_xxx
38
+ task_id TEXT NOT NULL,
39
+ actor_id TEXT,
40
+ from_status TEXT,
41
+ to_status TEXT NOT NULL,
42
+ note TEXT,
43
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
44
+ )
45
+ `);
46
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_build_task_events ON build_task_events(task_id, created_at)`);
47
+ }
48
+ function logTaskEvent(db, taskId, actorId, from, to, note) {
49
+ db.prepare(`INSERT INTO build_task_events (id, task_id, actor_id, from_status, to_status, note) VALUES (?,?,?,?,?,?)`)
50
+ .run(generateId('btev'), taskId, actorId, from, to, note);
51
+ }
52
+ // 惰性释放过期认领:claimed 且 claim_expires_at 已过 → 回 open(in_review 不动,它有 PR)。
53
+ // 在每次 list / claim 前调用,无需 cron。
54
+ export function releaseExpiredClaims(db) {
55
+ const expired = db.prepare(`SELECT id FROM build_tasks WHERE status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at < datetime('now')`).all();
56
+ for (const t of expired) {
57
+ db.prepare(`UPDATE build_tasks SET status='open', claimer_id=NULL, claimer_provenance=NULL,
58
+ claimed_at=NULL, claim_expires_at=NULL, updated_at=datetime('now') WHERE id = ? AND status='claimed'`).run(t.id);
59
+ logTaskEvent(db, t.id, null, 'claimed', 'open', 'auto-release: claim expired');
60
+ }
61
+ return expired.length;
62
+ }
63
+ export function createBuildTask(db, input) {
64
+ const title = String(input.title || '').trim();
65
+ if (title.length < 3)
66
+ return { error: '标题太短(至少 3 字)', error_code: 'TITLE_TOO_SHORT' };
67
+ if (title.length > TITLE_MAX)
68
+ return { error: `标题过长(上限 ${TITLE_MAX})`, error_code: 'TITLE_TOO_LONG' };
69
+ const description = input.description ? String(input.description).slice(0, TEXT_MAX) : null;
70
+ const area = input.area ? String(input.area).slice(0, 64) : null;
71
+ const rfcRef = input.rfcRef ? String(input.rfcRef).slice(0, 64) : null;
72
+ const todayCount = db.prepare(`SELECT COUNT(*) AS n FROM build_tasks WHERE created_by = ? AND created_at > datetime('now','-1 day')`).get(input.creatorId).n;
73
+ if (todayCount >= CREATE_RATE_PER_DAY) {
74
+ return { error: `今日建任务已达上限(${CREATE_RATE_PER_DAY}/天)`, error_code: 'RATE_LIMITED' };
75
+ }
76
+ const id = generateId('bt');
77
+ db.prepare(`INSERT INTO build_tasks (id, title, area, description, rfc_ref, status, created_by)
78
+ VALUES (?,?,?,?,?, 'open', ?)`).run(id, title, area, description, rfcRef, input.creatorId);
79
+ logTaskEvent(db, id, input.creatorId, null, 'open', 'created');
80
+ return { id, status: 'open' };
81
+ }
82
+ export function listBuildTasks(db, f = {}) {
83
+ releaseExpiredClaims(db); // 先回收过期占坑,列表才准
84
+ const where = [];
85
+ const params = [];
86
+ if (f.status && TASK_STATUS.has(f.status)) {
87
+ where.push('status = ?');
88
+ params.push(f.status);
89
+ }
90
+ if (f.area) {
91
+ where.push('area = ?');
92
+ params.push(String(f.area).slice(0, 64));
93
+ }
94
+ if (f.claimerId) {
95
+ where.push('claimer_id = ?');
96
+ params.push(f.claimerId);
97
+ }
98
+ const sql = `SELECT id, title, area, description, rfc_ref, status, claimer_id, claimer_provenance,
99
+ pr_ref, claimed_at, claim_expires_at, created_by, resolution, created_at, updated_at
100
+ FROM build_tasks ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
101
+ ORDER BY (status='open') DESC, updated_at DESC LIMIT 200`;
102
+ return db.prepare(sql).all(...params);
103
+ }
104
+ export function getBuildTask(db, id) {
105
+ releaseExpiredClaims(db);
106
+ const row = db.prepare(`SELECT * FROM build_tasks WHERE id = ?`).get(id);
107
+ if (!row)
108
+ return null;
109
+ row.events = db.prepare(`SELECT actor_id, from_status, to_status, note, created_at FROM build_task_events WHERE task_id = ? ORDER BY created_at`).all(id);
110
+ return row;
111
+ }
112
+ export function claimBuildTask(db, taskId, userId, provenance) {
113
+ releaseExpiredClaims(db);
114
+ const prov = provenance && TASK_PROVENANCE.has(provenance) ? provenance : 'human';
115
+ const active = db.prepare(`SELECT COUNT(*) AS n FROM build_tasks WHERE claimer_id = ? AND status IN ('claimed','in_review')`).get(userId).n;
116
+ if (active >= MAX_ACTIVE_CLAIMS) {
117
+ return { error: `你已持有 ${active} 个进行中的任务(上限 ${MAX_ACTIVE_CLAIMS}),先完成或释放再认领`, error_code: 'TOO_MANY_CLAIMS' };
118
+ }
119
+ // 原子认领:只有 open 才能被领;并发下只有一个成功
120
+ const expiresExpr = `datetime('now','+${CLAIM_TTL_DAYS} days')`;
121
+ const upd = db.prepare(`UPDATE build_tasks SET status='claimed', claimer_id=?, claimer_provenance=?,
122
+ claimed_at=datetime('now'), claim_expires_at=${expiresExpr}, updated_at=datetime('now')
123
+ WHERE id = ? AND status='open'`).run(userId, prov, taskId);
124
+ if (upd.changes === 0) {
125
+ const exist = db.prepare(`SELECT status FROM build_tasks WHERE id = ?`).get(taskId);
126
+ if (!exist)
127
+ return { error: '任务不存在', error_code: 'NOT_FOUND' };
128
+ return { error: `任务当前状态为 ${exist.status},不可认领(只有 open 可领)`, error_code: 'NOT_OPEN' };
129
+ }
130
+ logTaskEvent(db, taskId, userId, 'open', 'claimed', `provenance=${prov}`);
131
+ const row = db.prepare(`SELECT claim_expires_at FROM build_tasks WHERE id = ?`).get(taskId);
132
+ return { id: taskId, status: 'claimed', claim_expires_at: row.claim_expires_at };
133
+ }
134
+ export function submitBuildTask(db, taskId, userId, prRef, note) {
135
+ const t = db.prepare(`SELECT status, claimer_id FROM build_tasks WHERE id = ?`).get(taskId);
136
+ if (!t)
137
+ return { error: '任务不存在', error_code: 'NOT_FOUND' };
138
+ if (t.claimer_id !== userId)
139
+ return { error: '只有认领者可提交', error_code: 'NOT_CLAIMER' };
140
+ if (t.status !== 'claimed')
141
+ return { error: `任务状态为 ${t.status},仅 claimed 可提交进 in_review`, error_code: 'BAD_STATE' };
142
+ const pr = prRef ? String(prRef).slice(0, 300) : null;
143
+ db.prepare(`UPDATE build_tasks SET status='in_review', pr_ref=?, updated_at=datetime('now') WHERE id = ? AND status='claimed'`).run(pr, taskId);
144
+ logTaskEvent(db, taskId, userId, 'claimed', 'in_review', note ? `pr=${pr || '?'} note=${String(note).slice(0, 200)}` : `pr=${pr || '?'}`);
145
+ return { id: taskId, status: 'in_review' };
146
+ }
147
+ // 认领者主动放弃 → 回 open(让别人接)
148
+ export function releaseBuildTask(db, taskId, userId) {
149
+ const t = db.prepare(`SELECT status, claimer_id FROM build_tasks WHERE id = ?`).get(taskId);
150
+ if (!t)
151
+ return { error: '任务不存在', error_code: 'NOT_FOUND' };
152
+ if (t.claimer_id !== userId)
153
+ return { error: '只有认领者可释放', error_code: 'NOT_CLAIMER' };
154
+ if (t.status !== 'claimed' && t.status !== 'in_review')
155
+ return { error: `任务状态为 ${t.status},无可释放`, error_code: 'BAD_STATE' };
156
+ db.prepare(`UPDATE build_tasks SET status='open', claimer_id=NULL, claimer_provenance=NULL,
157
+ claimed_at=NULL, claim_expires_at=NULL, pr_ref=NULL, updated_at=datetime('now') WHERE id = ?`).run(taskId);
158
+ logTaskEvent(db, taskId, userId, t.status, 'open', 'released by claimer');
159
+ return { id: taskId, status: 'open' };
160
+ }
161
+ // 验收终态 done / abandoned —— **仅 admin/maintainer**(验收=真人,RFC-006 不变量 2)。
162
+ // 注:此处不发奖励/不记 build_reputation;那是 stage 4(独立池)的事。
163
+ export function resolveBuildTask(db, taskId, status, adminId, note) {
164
+ if (status !== 'done' && status !== 'abandoned')
165
+ return { error: "status 必须是 done | abandoned", error_code: 'BAD_STATUS' };
166
+ const t = db.prepare(`SELECT status, claimer_id FROM build_tasks WHERE id = ?`).get(taskId);
167
+ if (!t)
168
+ return { error: '任务不存在', error_code: 'NOT_FOUND' };
169
+ db.prepare(`UPDATE build_tasks SET status=?, resolution=?, resolved_by=?, updated_at=datetime('now') WHERE id = ?`)
170
+ .run(status, note ? String(note).slice(0, TEXT_MAX) : null, adminId, taskId);
171
+ logTaskEvent(db, taskId, adminId, t.status, status, note ? String(note).slice(0, 200) : null);
172
+ // 验收 done → 给认领者记【建设】信誉(独立池;奖励锚真人:仅 Passkey 用户记分)。防重复见 creditBuildReputation。
173
+ if (status === 'done' && t.claimer_id && t.status !== 'done') {
174
+ const hasAnchor = ((db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
175
+ .get(t.claimer_id)?.n) || 0) > 0;
176
+ if (hasAnchor)
177
+ creditBuildReputation(db, t.claimer_id, 'task_done', BUILD_POINTS.task_done, taskId, `task ${taskId} done`);
178
+ }
179
+ return { id: taskId, status };
180
+ }
@@ -736,6 +736,7 @@ async function render(page, params) {
736
736
  case 'kyc': return renderKyc(app)
737
737
  case 'feedback': return renderFeedback(app)
738
738
  case 'build-feedback': return renderMyBuildFeedback(app)
739
+ case 'my-contributions': return renderMyContributions(app)
739
740
  case 'admin-feedback': return renderAdminFeedback(app)
740
741
  case 'admin-payments': return renderAdminPayments(app)
741
742
  case 'my-agents': return renderMyAgents(app)
@@ -9756,6 +9757,54 @@ async function renderMyBuildFeedback(app) {
9756
9757
  `, 'me')
9757
9758
  }
9758
9759
 
9760
+ // RFC-006 Gap 2:贡献者【自查】看板 — KPI / 等级 / 限制 + 申诉。仅自查(私密),建设信誉独立池不解锁交易角色。
9761
+ async function renderMyContributions(app) {
9762
+ if (!state.user) { renderLogin(); return }
9763
+ app.innerHTML = shell(loading$(), 'me')
9764
+ const p = await GET('/build-reputation/me')
9765
+ if (!p || p.error) { app.innerHTML = shell(`<div class="card" style="padding:16px;color:#b91c1c">${escHtml(p?.error || t('加载失败'))}</div>`, 'me'); return }
9766
+ const lang = window._lang === 'zh' ? 'zh' : 'en'
9767
+ const tier = p.tier || {}
9768
+ const k = p.kpi || {}
9769
+ const stat = (label, val) => `<div class="card" style="padding:10px;text-align:center"><div style="font-size:20px;font-weight:700;color:#4f46e5">${val ?? 0}</div><div style="font-size:11px;color:#6b7280">${label}</div></div>`
9770
+ const provRows = (p.provenance || []).map(x => {
9771
+ const m = { human: t('真人'), ai_assisted: t('AI 辅助'), ai_authored: t('AI 主笔'), unspecified: t('未声明') }
9772
+ return `<span style="font-size:11px;color:#6b7280;margin-right:10px">${m[x.provenance] || x.provenance}: <b>${x.count}</b></span>`
9773
+ }).join('')
9774
+ const restr = (p.restrictions || [])
9775
+ const restrHtml = restr.length === 0
9776
+ ? `<div style="font-size:12px;color:#16a34a">✓ ${t('无限制')}</div>`
9777
+ : restr.map(r => `<div class="card" style="padding:10px;margin-bottom:6px;border-left:3px solid #dc2626">
9778
+ <div style="font-size:12px;color:#dc2626;font-weight:600">${escHtml(String(r.reason_code || ''))} · ${escHtml(String(r.severity || ''))}</div>
9779
+ <div style="font-size:12px;color:#374151">${escHtml(String(r.reason_detail || ''))}</div>
9780
+ <div style="font-size:11px;color:#6b7280;margin-top:4px">${t('如有异议可申诉')} → <code>/api/me/agents/strikes/${escHtml(String(r.id))}/appeal</code></div>
9781
+ </div>`).join('')
9782
+ app.innerHTML = shell(`
9783
+ <h1 class="page-title">🛠️ ${t('我的共建')}</h1>
9784
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('你的建设贡献(独立于交易信誉 — 不影响 verifier/仲裁准入)')}</div>
9785
+ <div class="card" style="padding:14px;margin-bottom:12px;background:linear-gradient(135deg,#eef2ff,#faf5ff)">
9786
+ <div style="display:flex;justify-content:space-between;align-items:center">
9787
+ <div><div style="font-size:11px;color:#6b7280">${t('建设等级')}</div><div style="font-size:18px;font-weight:700">${lang === 'zh' ? (tier.label_zh || '') : (tier.label_en || '')}</div></div>
9788
+ <div style="text-align:right"><div style="font-size:11px;color:#6b7280">${t('建设积分')}</div><div style="font-size:18px;font-weight:700;color:#4f46e5">${p.build_points ?? 0}</div></div>
9789
+ </div>
9790
+ <div style="font-size:11px;color:#6b7280;margin-top:8px">${t('当前可参与')}:${lang === 'zh' ? (tier.caps_zh || '') : (tier.caps_en || '')}${tier.next_at ? ` · ${t('下一级')} @ ${tier.next_at}` : ''}</div>
9791
+ </div>
9792
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px">
9793
+ ${stat(t('认领中'), k.tasks_claimed)}
9794
+ ${stat(t('审核中'), k.tasks_in_review)}
9795
+ ${stat(t('已验收'), k.tasks_done)}
9796
+ ${stat(t('提反馈'), k.feedback_submitted)}
9797
+ ${stat(t('被采纳'), k.feedback_accepted)}
9798
+ ${stat(t('提任务'), k.tasks_created)}
9799
+ </div>
9800
+ ${provRows ? `<div class="card" style="padding:10px;margin-bottom:12px"><div style="font-size:11px;color:#6b7280;margin-bottom:4px">${t('署名构成(自报)')}</div>${provRows}</div>` : ''}
9801
+ ${!p.reward_anchored ? `<div class="card" style="padding:10px;margin-bottom:12px;border-left:3px solid #d97706"><div style="font-size:12px;color:#92400e">${t('未绑 Passkey:贡献可受理致谢,但需绑定真人锚点才记入建设信誉。')}</div></div>` : ''}
9802
+ <div style="font-size:13px;font-weight:600;margin:14px 0 6px">${t('限制与申诉')}</div>
9803
+ ${restrHtml}
9804
+ <div style="font-size:11px;color:#9ca3af;margin-top:14px">${t('看板仅自己可见,不做公开排行。')}</div>
9805
+ `, 'me')
9806
+ }
9807
+
9759
9808
  // W7 客服 ticket-thread 视图
9760
9809
  const TICKET_TYPE_META = {
9761
9810
  created: { icon: '🛟', title: '新建工单', border: '#d97706' },
@@ -6047,6 +6047,29 @@ const _EN = {
6047
6047
  '体验问题': 'UX issue',
6048
6048
  '提案': 'Proposal',
6049
6049
  '共建信誉': 'Co-build reputation',
6050
+ // RFC-006 Gap 2:贡献者看板
6051
+ '我的共建': 'My contributions',
6052
+ '你的建设贡献(独立于交易信誉 — 不影响 verifier/仲裁准入)': 'Your building contributions (separate from trade reputation — does NOT gate verifier/arbitrator)',
6053
+ '建设等级': 'Build tier',
6054
+ '建设积分': 'Build points',
6055
+ '当前可参与': 'Can currently do',
6056
+ '下一级': 'Next tier',
6057
+ '认领中': 'Claimed',
6058
+ '审核中': 'In review',
6059
+ '已验收': 'Accepted (done)',
6060
+ '提反馈': 'Feedback sent',
6061
+ '被采纳': 'Accepted',
6062
+ '提任务': 'Tasks created',
6063
+ '署名构成(自报)': 'Authorship (self-declared)',
6064
+ '真人': 'Human',
6065
+ 'AI 辅助': 'AI-assisted',
6066
+ 'AI 主笔': 'AI-authored',
6067
+ '未声明': 'Unspecified',
6068
+ '未绑 Passkey:贡献可受理致谢,但需绑定真人锚点才记入建设信誉。': 'No Passkey: contributions are acknowledged, but earning build reputation requires a real-person Passkey anchor.',
6069
+ '限制与申诉': 'Restrictions & appeal',
6070
+ '无限制': 'No restrictions',
6071
+ '如有异议可申诉': 'Appeal if you disagree',
6072
+ '看板仅自己可见,不做公开排行。': 'This dashboard is private to you — no public leaderboard.',
6050
6073
  }
6051
6074
 
6052
6075
  window.t = (zh) => window._lang === 'en' ? (_EN[zh] || zh) : zh
@@ -1,4 +1,4 @@
1
- import { submitBuildFeedback, listMyBuildFeedback, getBuildFeedback, adminListBuildFeedback, adminUpdateBuildFeedback, } from '../../layer2-business/L2-8-feedback/build-feedback-engine.js';
1
+ import { submitBuildFeedback, listMyBuildFeedback, getBuildFeedback, adminListBuildFeedback, adminUpdateBuildFeedback, triagePendingBuildFeedback, } from '../../layer2-business/L2-8-feedback/build-feedback-engine.js';
2
2
  export function registerBuildFeedbackRoutes(app, deps) {
3
3
  const { db, auth, requireSupportAdmin } = deps;
4
4
  const hasPasskey = (userId) => ((db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(userId)?.n) || 0) > 0;
@@ -52,13 +52,29 @@ export function registerBuildFeedbackRoutes(app, deps) {
52
52
  const status = typeof req.query.status === 'string' ? req.query.status : undefined;
53
53
  res.json({ feedback: adminListBuildFeedback(db, status) });
54
54
  });
55
+ // RFC-005 Phase 2:AI 自动 triage(advisory)— 批量处理 received 反馈:去重 + 标风险/摘要 + 置 triaged。
56
+ // 不 resolve、不记功(人类的)。无 AI key 时只做确定性去重 + 置 triaged。
57
+ // ⚠️ 必须在 /:id 之前声明,否则 'triage' 会被 :id 捕获。
58
+ app.post('/api/admin/build-feedback/triage', async (req, res) => {
59
+ if (!requireSupportAdmin(req, res))
60
+ return;
61
+ const limit = Math.min(50, Math.max(1, Number((req.body ?? {}).limit) || 20));
62
+ try {
63
+ const r = await triagePendingBuildFeedback(db, limit);
64
+ res.json(r);
65
+ }
66
+ catch (e) {
67
+ res.status(500).json({ error: 'triage failed', detail: String(e.message) });
68
+ }
69
+ });
55
70
  app.post('/api/admin/build-feedback/:id', (req, res) => {
56
71
  const admin = requireSupportAdmin(req, res);
57
72
  if (!admin)
58
73
  return;
59
- const { status, resolution, rfc_draft, credit } = req.body ?? {};
74
+ const { status, resolution, rfc_draft, credit, promote_to_task } = req.body ?? {};
60
75
  const result = adminUpdateBuildFeedback(db, {
61
- id: String(req.params.id), status, resolution, rfcDraft: rfc_draft, credit: !!credit, adminId: admin.id,
76
+ id: String(req.params.id), status, resolution, rfcDraft: rfc_draft, credit: !!credit,
77
+ promoteToTask: !!promote_to_task, adminId: admin.id,
62
78
  });
63
79
  if ('error' in result)
64
80
  return void res.status(404).json(result);
@@ -0,0 +1,10 @@
1
+ import { getBuildProfile } from '../../layer2-business/L2-9-contribution/build-reputation-engine.js';
2
+ export function registerBuildReputationRoutes(app, deps) {
3
+ const { db, auth } = deps;
4
+ app.get('/api/build-reputation/me', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ res.json(getBuildProfile(db, user.id));
9
+ });
10
+ }
@@ -0,0 +1,73 @@
1
+ import { createBuildTask, listBuildTasks, getBuildTask, claimBuildTask, submitBuildTask, releaseBuildTask, resolveBuildTask, } from '../../layer2-business/L2-9-contribution/build-tasks-engine.js';
2
+ export function registerBuildTasksRoutes(app, deps) {
3
+ const { db, auth, requireSupportAdmin } = deps;
4
+ app.post('/api/build-tasks', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ const { title, area, description, rfc_ref } = req.body ?? {};
9
+ const result = createBuildTask(db, { creatorId: user.id, title, area, description, rfcRef: rfc_ref });
10
+ if ('error' in result)
11
+ return void res.status(result.error_code === 'RATE_LIMITED' ? 429 : 400).json(result);
12
+ res.json(result);
13
+ });
14
+ app.get('/api/build-tasks', (req, res) => {
15
+ const user = auth(req, res);
16
+ if (!user)
17
+ return;
18
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
19
+ const area = typeof req.query.area === 'string' ? req.query.area : undefined;
20
+ const claimerId = req.query.mine === '1' ? user.id : undefined;
21
+ res.json({ tasks: listBuildTasks(db, { status, area, claimerId }) });
22
+ });
23
+ app.get('/api/build-tasks/:id', (req, res) => {
24
+ const user = auth(req, res);
25
+ if (!user)
26
+ return;
27
+ const row = getBuildTask(db, String(req.params.id));
28
+ if (!row)
29
+ return void res.status(404).json({ error: '任务不存在' });
30
+ res.json(row);
31
+ });
32
+ app.post('/api/build-tasks/:id/claim', (req, res) => {
33
+ const user = auth(req, res);
34
+ if (!user)
35
+ return;
36
+ const result = claimBuildTask(db, String(req.params.id), user.id, (req.body ?? {}).provenance);
37
+ if ('error' in result) {
38
+ const code = result.error_code === 'NOT_FOUND' ? 404 : result.error_code === 'TOO_MANY_CLAIMS' ? 429 : 409;
39
+ return void res.status(code).json(result);
40
+ }
41
+ res.json(result);
42
+ });
43
+ app.post('/api/build-tasks/:id/submit', (req, res) => {
44
+ const user = auth(req, res);
45
+ if (!user)
46
+ return;
47
+ const { pr_ref, note } = req.body ?? {};
48
+ const result = submitBuildTask(db, String(req.params.id), user.id, pr_ref, note);
49
+ if ('error' in result)
50
+ return void res.status(result.error_code === 'NOT_FOUND' ? 404 : 400).json(result);
51
+ res.json(result);
52
+ });
53
+ app.post('/api/build-tasks/:id/release', (req, res) => {
54
+ const user = auth(req, res);
55
+ if (!user)
56
+ return;
57
+ const result = releaseBuildTask(db, String(req.params.id), user.id);
58
+ if ('error' in result)
59
+ return void res.status(result.error_code === 'NOT_FOUND' ? 404 : 400).json(result);
60
+ res.json(result);
61
+ });
62
+ // 验收终态 —— 仅 admin/maintainer(验收=真人,RFC-006 不变量 2;不发奖励/不记信誉)
63
+ app.post('/api/admin/build-tasks/:id/resolve', (req, res) => {
64
+ const admin = requireSupportAdmin(req, res);
65
+ if (!admin)
66
+ return;
67
+ const { status, note } = req.body ?? {};
68
+ const result = resolveBuildTask(db, String(req.params.id), String(status), admin.id, note);
69
+ if ('error' in result)
70
+ return void res.status(result.error_code === 'NOT_FOUND' ? 404 : 400).json(result);
71
+ res.json(result);
72
+ });
73
+ }
@@ -223,6 +223,11 @@ export function registerOrdersCreateRoutes(app, deps) {
223
223
  const buyerRegionSnapshot = buyer?.region || 'global';
224
224
  // P2P:若为 P2P 商品,下单时快照 content_hash(争议时凭买家所见 hash 判定)
225
225
  const contentHashSnapshot = (Number(product.p2p_mode) === 1 && product.content_hash) ? String(product.content_hash) : null;
226
+ // RFC-008 stage 1:赔付背书快照。起步免赔付阶段(require_seller_stake=0)= 0 → 违约只退款不没收、不印钱、零门槛。
227
+ // ⚠️ "要求质押"档(=1,下单锁 stake、backing=total×stake_rate(信誉))属后续【收紧】阶段,届时在此计算并锁定。
228
+ const stakeBacking = Number(getProtocolParam('require_seller_stake', 0)) === 1
229
+ ? 0 // TODO(tighten stage): backing = total × stakeRate(seller reputation) + 在此 lock balance→staked
230
+ : 0;
226
231
  db.prepare(`INSERT INTO orders (
227
232
  id, product_id, buyer_id, seller_id, quantity, unit_price, total_amount, escrow_amount,
228
233
  status, shipping_address, notes, pay_deadline, accept_deadline, ship_deadline,
@@ -230,8 +235,8 @@ export function registerOrdersCreateRoutes(app, deps) {
230
235
  l1_uid, l2_uid, l3_uid, snapshot_commission_rate, buyer_region, content_hash_at_order,
231
236
  delivery_window, variant_id, variant_options_snapshot,
232
237
  gift_recipient_name, gift_recipient_phone, gift_message, insurance_premium,
233
- anonymous_recipient, recipient_code, donation_amount
234
- ) VALUES (?,?,?,?,?,?,?,?,'created',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(orderId, product.id, user.id, product.seller_uid, reqQty, basePrice, totalAmount, totalAmount, shipping_address, notes || null, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408), l1, l2, l3, snapshotRate, buyerRegionSnapshot, contentHashSnapshot, deliveryWindowJson, variant ? variant.id : null, variant ? variant.options_json : null, giftRecipientName, giftRecipientPhone, giftMessage, insurancePremium, anonymousFlag, recipientCode, donationAmount);
238
+ anonymous_recipient, recipient_code, donation_amount, stake_backing
239
+ ) VALUES (?,?,?,?,?,?,?,?,'created',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(orderId, product.id, user.id, product.seller_uid, reqQty, basePrice, totalAmount, totalAmount, shipping_address, notes || null, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408), l1, l2, l3, snapshotRate, buyerRegionSnapshot, contentHashSnapshot, deliveryWindowJson, variant ? variant.id : null, variant ? variant.options_json : null, giftRecipientName, giftRecipientPhone, giftMessage, insurancePremium, anonymousFlag, recipientCode, donationAmount, stakeBacking);
235
240
  // 协议层:写 genesis 事件 — order 创建(必然是 buyer 自己)
236
241
  try {
237
242
  appendOrderEvent(db, {
@@ -125,14 +125,21 @@ export function registerWalletWriteRoutes(app, deps) {
125
125
  });
126
126
  }
127
127
  }
128
- // WebAuthn gate
129
- // opt-in webauthn_required_for_withdraw 所有金额都要 Passkey
130
- // #1009 大额自动强制 已注册 Passkey + 金额 > LARGE_WITHDRAW_THRESHOLD 一律走 Passkey
131
- // 未注册 Passkey 的用户走原邮件确认路径(不强制注册,避免新用户卡死)
132
- const hasPasskeyRow = db.prepare('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?').get(user.id);
133
- const hasPasskey = (hasPasskeyRow?.n || 0) > 0;
134
- const forceWebauthnByAmount = amountNum > LARGE_WITHDRAW_THRESHOLD && hasPasskey;
135
- if (user.webauthn_required_for_withdraw || forceWebauthnByAmount) {
128
+ // WebAuthn gate — #1115 全额对齐铁律:**所有**提现都要真人 Passkey 一次性 token。
129
+ // 资金转出 = 真人在场(spec §4 铁律,与 vote/arbitrate/agent_revoke 同档)。
130
+ // email-OTP agent 威胁模型下不足(agent 可读监护人收件箱);故弃用旧的"非 Passkey email 兜底"路径。
131
+ // 未注册 Passkey 的账户:不能提现,先去「安全」绑 Passkey(pre-launch 0 真用户,推动资金操作 Passkey 化)。
132
+ const hpEnabled = Number(getProtocolParam('require_human_presence_for_withdraw', 1)) === 1;
133
+ if (hpEnabled) {
134
+ const hasPasskeyRow = db.prepare('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?').get(user.id);
135
+ const hasPasskey = (hasPasskeyRow?.n || 0) > 0;
136
+ if (!hasPasskey) {
137
+ return void res.status(403).json({
138
+ error: '提现需先绑定 Passkey(资金转出需真人在场,铁律)。请到「安全」页绑定后再试。',
139
+ error_code: 'PASSKEY_REQUIRED_FOR_WITHDRAW',
140
+ requires_passkey_setup: true,
141
+ });
142
+ }
136
143
  const token = req.headers['x-webauthn-token'];
137
144
  const gate = consumeGateToken(user.id, token, 'withdraw', (data) => {
138
145
  const d = (data || {});
@@ -144,8 +151,7 @@ export function registerWalletWriteRoutes(app, deps) {
144
151
  webauthn_required: true,
145
152
  purpose: 'withdraw',
146
153
  purpose_data: { to_address, amount: amountNum },
147
- force_reason: forceWebauthnByAmount && !user.webauthn_required_for_withdraw
148
- ? `large_withdraw_auto:${LARGE_WITHDRAW_THRESHOLD}` : 'user_opted_in',
154
+ force_reason: 'iron_rule_withdraw',
149
155
  });
150
156
  }
151
157
  }
@@ -167,27 +173,7 @@ export function registerWalletWriteRoutes(app, deps) {
167
173
  const mins = Math.ceil((new Date(wl.activates_at.replace(' ', 'T') + 'Z').getTime() - Date.now()) / 60_000);
168
174
  return void res.json({ error: `该地址在冷却期内,约 ${mins} 分钟后可用(添加后 24h 强制冷却)` });
169
175
  }
170
- // 大额提现强制邮件确认
171
- const isLarge = amountNum > LARGE_WITHDRAW_THRESHOLD;
172
- if (isLarge) {
173
- if (!user.email_verified || !user.email) {
174
- return void res.json({ error: `大额提现(> ${LARGE_WITHDRAW_THRESHOLD} WAZ)需先绑定邮箱用于二次确认` });
175
- }
176
- // 不立即扣款 + 不写入正式 pending — 进入 pending_email 阶段,待 confirm
177
- const wid = generateId('wdr');
178
- db.prepare(`INSERT INTO withdrawal_requests (id, user_id, to_address, amount, status, status_detail)
179
- VALUES (?,?,?,?,?,?)`)
180
- .run(wid, user.id, to_address, amountNum, 'pending_email', 'awaiting_email_confirm');
181
- issueCode(user.id, 'email', user.email, 'withdraw_confirm:' + wid);
182
- return void res.json({
183
- success: true,
184
- request_id: wid,
185
- requires_email_code: true,
186
- email: maskEmail(user.email),
187
- message: '已发送邮件验证码,请查收并输入 6 位数字以确认提现',
188
- });
189
- }
190
- // 普通额度 → 即时扣款 + pending(admin 处理)
176
+ // Passkey 已过真人门 即时扣款 + pending(admin 处理)。各金额一致(大额二次邮件确认已被 Passkey 取代)。
191
177
  const wid = generateId('wdr');
192
178
  db.prepare(`INSERT INTO withdrawal_requests (id, user_id, to_address, amount) VALUES (?,?,?,?)`)
193
179
  .run(wid, user.id, to_address, amountNum);