@seasonkoh/webaz 0.1.26 → 0.1.28

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 (99) hide show
  1. package/LICENSE +2 -2
  2. package/NOTICE +24 -3
  3. package/README.md +74 -330
  4. package/README.zh-CN.md +419 -0
  5. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
  6. package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
  7. package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +69 -0
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +270 -82
  10. package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
  11. package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
  12. package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
  13. package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
  14. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +24 -0
  15. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
  16. package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
  17. package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
  18. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +57 -7
  19. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
  20. package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
  21. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +187 -18
  22. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
  23. package/dist/ledger.js +1 -1
  24. package/dist/pwa/admin-audit.js +38 -0
  25. package/dist/pwa/anti-abuse-thresholds.js +135 -0
  26. package/dist/pwa/cf-origin-guard.js +33 -0
  27. package/dist/pwa/contract-fingerprint.js +1 -0
  28. package/dist/pwa/data/onboarding-cases.js +2 -2
  29. package/dist/pwa/data/onboarding-quiz.js +1 -1
  30. package/dist/pwa/economic-participation.js +2 -2
  31. package/dist/pwa/integration-contract.js +46 -4
  32. package/dist/pwa/internal/pv-settlement.js +12 -0
  33. package/dist/pwa/internal/wallet-signer.js +26 -0
  34. package/dist/pwa/public/app-account.js +977 -0
  35. package/dist/pwa/public/app-admin.js +608 -0
  36. package/dist/pwa/public/app-agents.js +63 -0
  37. package/dist/pwa/public/app-ai.js +2162 -0
  38. package/dist/pwa/public/app-contribution.js +836 -0
  39. package/dist/pwa/public/app-discover.js +1296 -0
  40. package/dist/pwa/public/app-listings.js +226 -0
  41. package/dist/pwa/public/app-profile.js +1692 -0
  42. package/dist/pwa/public/app-seller.js +199 -0
  43. package/dist/pwa/public/app-shop.js +1145 -0
  44. package/dist/pwa/public/app.js +15075 -23960
  45. package/dist/pwa/public/i18n.js +31 -28
  46. package/dist/pwa/public/index.html +11 -1
  47. package/dist/pwa/public/openapi.json +4851 -2776
  48. package/dist/pwa/pv-kill-switch.js +31 -0
  49. package/dist/pwa/routes/admin-admins.js +48 -1
  50. package/dist/pwa/routes/admin-analytics.js +1 -10
  51. package/dist/pwa/routes/admin-atomic.js +4 -17
  52. package/dist/pwa/routes/admin-operator-claims.js +280 -0
  53. package/dist/pwa/routes/admin-reports.js +4 -26
  54. package/dist/pwa/routes/admin-tokenomics.js +2 -76
  55. package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
  56. package/dist/pwa/routes/admin-users-query.js +23 -1
  57. package/dist/pwa/routes/admin-wallet-ops.js +1 -1
  58. package/dist/pwa/routes/agent-grants.js +255 -0
  59. package/dist/pwa/routes/auth-read.js +1 -5
  60. package/dist/pwa/routes/auth-register.js +3 -13
  61. package/dist/pwa/routes/build-task-quota.js +113 -0
  62. package/dist/pwa/routes/claim-verify.js +15 -11
  63. package/dist/pwa/routes/contribution-facts.js +18 -0
  64. package/dist/pwa/routes/dispute-cases.js +5 -4
  65. package/dist/pwa/routes/growth.js +3 -3
  66. package/dist/pwa/routes/orders-action.js +27 -10
  67. package/dist/pwa/routes/orders-create.js +1 -1
  68. package/dist/pwa/routes/products-meta.js +19 -6
  69. package/dist/pwa/routes/profile-placement.js +1 -1
  70. package/dist/pwa/routes/promoter.js +10 -29
  71. package/dist/pwa/routes/public-build-tasks.js +5 -1
  72. package/dist/pwa/routes/public-utils.js +9 -12
  73. package/dist/pwa/routes/referral.js +5 -26
  74. package/dist/pwa/routes/rewards-apply.js +3 -2
  75. package/dist/pwa/routes/share-redirects.js +1 -1
  76. package/dist/pwa/routes/shareables-interactions.js +2 -1
  77. package/dist/pwa/routes/task-proposals.js +85 -9
  78. package/dist/pwa/routes/users-public.js +1 -4
  79. package/dist/pwa/routes/wallet-read.js +2 -14
  80. package/dist/pwa/routes/webauthn.js +7 -2
  81. package/dist/pwa/server-schema.js +9 -0
  82. package/dist/pwa/server.js +319 -2034
  83. package/dist/runtime/agent-grant-scopes.js +128 -0
  84. package/dist/runtime/agent-grant-verifier.js +67 -0
  85. package/dist/runtime/agent-pairing.js +60 -0
  86. package/dist/runtime/apply-webaz-runtime-schema.js +15 -0
  87. package/dist/runtime/webaz-schema-helpers.js +1848 -0
  88. package/dist/settlement-math.js +3 -3
  89. package/dist/version.js +6 -4
  90. package/package.json +43 -8
  91. package/dist/index.js +0 -182
  92. package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
  93. package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
  94. package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
  95. package/dist/test-dispute.js +0 -153
  96. package/dist/test-manifest.js +0 -61
  97. package/dist/test-mcp-tools.js +0 -135
  98. package/dist/test-reputation.js +0 -116
  99. package/dist/test-skill-market.js +0 -101
@@ -320,12 +320,27 @@ export function registerOrdersActionRoutes(app, deps) {
320
320
  commissionDistributed += Number(r.amount);
321
321
  }
322
322
  const commissionRedirected = round2(commissionPool - commissionDistributed);
323
- // QA 14.b P2:redirected_total chain_gap(→charity) vs region_cap(→global_fund)
324
- // 之前单一数字让 agent 无法分辨钱去哪(global region L2/L3 global_fund,不是 charity
325
- const charityRow = (await dbOne("SELECT COALESCE(SUM(amount),0) AS s FROM charity_fund_txns WHERE related_order_id = ?", [req.params.id]));
326
- const redirectedToCharity = round2(Number(charityRow.s));
327
- const fundDepRow = (await dbOne("SELECT COALESCE(SUM(amount_l3),0) AS s FROM fund_deposits WHERE order_id = ?", [req.params.id]));
328
- const redirectedToGlobalFund = round2(Number(fundDepRow.s));
323
+ // 2026-06-04 三科目解耦后:未发出的 commission 不再进 charity_fund / global_fund
324
+ // region_cap / chain_gap / orphan_sponsor / opt_out_deactivated commission_reserve(按 kind
325
+ // opt-out 未激活(never_activated / auto_downgrade) → pending_commission_escrow(30 天内 recipient opt-in 可恢复)
326
+ // 此处只读汇总本单去向,让 agent 看清 redirected_total 实际落点(settleOrder 已完成,无写、无原子性要求)。
327
+ const crRows = await dbAll("SELECT kind, COALESCE(SUM(amount),0) AS s FROM commission_reserve_txns WHERE related_order_id = ? GROUP BY kind", [req.params.id]);
328
+ const reserveByKind = { region_cap: 0, chain_gap: 0, orphan_sponsor: 0, opt_out_deactivated: 0, escrow_expired: 0 };
329
+ for (const r of crRows) {
330
+ if (r.kind === 'redirect_region_cap')
331
+ reserveByKind.region_cap = round2(Number(r.s));
332
+ else if (r.kind === 'redirect_chain_gap')
333
+ reserveByKind.chain_gap = round2(Number(r.s));
334
+ else if (r.kind === 'redirect_orphan_sponsor')
335
+ reserveByKind.orphan_sponsor = round2(Number(r.s));
336
+ else if (r.kind === 'redirect_opt_out_deactivated')
337
+ reserveByKind.opt_out_deactivated = round2(Number(r.s));
338
+ else if (r.kind === 'redirect_escrow_expired')
339
+ reserveByKind.escrow_expired = round2(Number(r.s));
340
+ }
341
+ const redirectedToCommissionReserve = round2(reserveByKind.region_cap + reserveByKind.chain_gap + reserveByKind.orphan_sponsor + reserveByKind.opt_out_deactivated + reserveByKind.escrow_expired);
342
+ const escrowRow = (await dbOne("SELECT COALESCE(SUM(amount),0) AS s FROM pending_commission_escrow WHERE order_id = ? AND status = 'pending'", [req.params.id]));
343
+ const heldInOptOutEscrow = round2(Number(escrowRow.s));
329
344
  // QA 轮 9.5 P2:payouts 表只 MCP legacy 写,PWA settleOrder 直更 wallet.balance 不写 payouts
330
345
  // 改用公式推算 sellerAmount(跟 PWA settleOrder 内部计算一致),更可靠
331
346
  const fundBase1pct = round2(total * 0.01);
@@ -336,7 +351,7 @@ export function registerOrdersActionRoutes(app, deps) {
336
351
  order_amount: total,
337
352
  distribution: {
338
353
  seller_net: { amount: sellerAmountComputed, to: ord.seller_id, note: '不含可能的首销 stake 锁定(settleOrder 内 stake_locked_at 首次锁,从 sellerAmount 划出)' },
339
- protocol_fund_2pct: { amount: protocolFee, split: { management_bonus_pool: round2(protocolFee / 2), sys_protocol_ops: round2(protocolFee / 2) } },
354
+ protocol_fund_2pct: { amount: protocolFee, split: { protocol_reserve_pool: round2(protocolFee / 2), sys_protocol_ops: round2(protocolFee / 2) } },
340
355
  logistics_fee: { amount: logisticsActual, rate: isInPerson ? 'N/A in_person' : (ord.logistics_id ? '5%' : 'N/A self-fulfill') },
341
356
  commission_pool: { total: commissionPool, rate: `${(commissionRate * 100).toFixed(1)}%` },
342
357
  commission_distribution_7_2_1: {
@@ -345,9 +360,11 @@ export function registerOrdersActionRoutes(app, deps) {
345
360
  l3: commByLevel[3],
346
361
  distributed_total: round2(commissionDistributed),
347
362
  redirected_total: commissionRedirected,
348
- redirected_to_charity: redirectedToCharity, // chain_gap (无 L / sponsor 无效)
349
- redirected_to_global_fund: redirectedToGlobalFund, // region cap (level > region max_levels)
350
- redirect_note: 'chain_gap (无 L) charity_fund; region cap (level > max_levels) → global_fund',
363
+ redirected_to_commission_reserve: redirectedToCommissionReserve,
364
+ reserve_by_kind: reserveByKind, // region_cap / chain_gap / orphan_sponsor / opt_out_deactivated / escrow_expired
365
+ held_in_opt_out_escrow: heldInOptOutEscrow, // never_activated / auto_downgrade recipient opt-in 可恢复
366
+ redirect_accounted_ok: Math.abs(commissionRedirected - round2(redirectedToCommissionReserve + heldInOptOutEscrow)) < 0.01,
367
+ redirect_note: '未发出佣金 → commission_reserve(region_cap / chain_gap / orphan_sponsor / opt_out_deactivated);opt-out 未激活(never_activated / auto_downgrade)暂存 pending_commission_escrow(30 天内 opt-in 可恢复),逾期未恢复则转入 commission_reserve(escrow_expired)。2026-06-04 起不再进 charity_fund / global_fund。',
351
368
  },
352
369
  fund_base_1pct: fundBase1pct,
353
370
  },
@@ -371,7 +371,7 @@ export function registerOrdersCreateRoutes(app, deps) {
371
371
  }
372
372
  transition(db, orderId, 'paid', user.id, [], '模拟支付完成');
373
373
  notifyTransition(db, orderId, 'created', 'paid');
374
- // 里程碑 3-C:双轨同支检测(监测+审计;不阻断)
374
+ // 里程碑 3-C:放置同支检测(监测+审计;不阻断)
375
375
  try {
376
376
  auditSponsorChainCross(orderId, user.id, String(product.seller_uid), buyer.sponsor_path);
377
377
  }
@@ -1,4 +1,17 @@
1
1
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
3
+ /**
4
+ * 真实收货完成的订单数(分享资格判据)。
5
+ * 关键:status='completed' 是状态机的通用终态 — 不只「无争议自然完成」(confirmed→completed),
6
+ * 还包括 fault_seller / fault_logistics / fault_buyer / declined_nofault / disputed → completed
7
+ * 这些退款 / 违约 / 争议处置终态。单看 status='completed' 会把「被退款的失败交易」当成有效成交,
8
+ * 错误授予分享(进而分享分润)资格。
9
+ * 真实收货 = 该订单曾进入过 confirmed(买家确认收货,或送达后 72h 自动确认)— 仅 happy path 经过,
10
+ * 所有 fault/争议/退款终态都不经过 confirmed,据此排除。
11
+ */
12
+ async function genuineReceiptCount(buyerId, productId) {
13
+ return (await dbOne(`SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND product_id = ? AND ${genuineSalePredicate('orders')}`, [buyerId, productId])).n;
14
+ }
2
15
  export function registerProductsMetaRoutes(app, deps) {
3
16
  const { db, auth, generateId, rateLimitOk, flagNewAccountShareable, refreshProductSharerCount } = deps;
4
17
  void db; // RFC-016: 本文件已全量走异步 seam;db 仍在 deps 由调用方注入,此处不直接使用
@@ -117,16 +130,16 @@ export function registerProductsMetaRoutes(app, deps) {
117
130
  return void res.status(404).json({ error: 'not_found' });
118
131
  res.json(row);
119
132
  });
120
- // 分享许可:是否对该商品有 completed 订单
133
+ // 分享许可:是否真实收货完成该商品(经过 confirmed,排除退款/违约/争议终态)
121
134
  app.get('/api/products/:id/can-share', async (req, res) => {
122
135
  const user = auth(req, res);
123
136
  if (!user)
124
137
  return;
125
- const completed = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'", [user.id, req.params.id])).n;
138
+ const completed = await genuineReceiptCount(user.id, req.params.id);
126
139
  res.json({
127
140
  can_share: completed > 0,
128
141
  completed_orders: completed,
129
- reason: completed > 0 ? 'verified_buyer_of_product' : 'need_completed_order_of_this_product',
142
+ reason: completed > 0 ? 'genuine_receipt_of_product' : 'need_genuine_receipt_of_this_product',
130
143
  });
131
144
  });
132
145
  // 获取或创建商品 shareable(被 sharePromoLink 用,走 /s/<id> 短链)
@@ -144,7 +157,7 @@ export function registerProductsMetaRoutes(app, deps) {
144
157
  };
145
158
  const minOrders = await getParam('rewards_opt_in.min_completed_orders', 1);
146
159
  const requirePasskey = await getParam('rewards_opt_in.require_passkey', 1);
147
- const totalCompleted = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [user.id])).n;
160
+ const totalCompleted = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [user.id])).n; // 真实成交,排除退款/违约
148
161
  const passkeyCount = (await dbOne("SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?", [user.id])).n;
149
162
  const missing = [];
150
163
  if (totalCompleted < minOrders)
@@ -165,9 +178,9 @@ export function registerProductsMetaRoutes(app, deps) {
165
178
  ],
166
179
  });
167
180
  }
168
- const completed = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'", [user.id, productId])).n;
181
+ const completed = await genuineReceiptCount(user.id, productId);
169
182
  if (completed === 0)
170
- return void res.json({ error: '需先完成该商品的购买才能分享', completed_orders: 0 });
183
+ return void res.json({ error: '需先真实收货完成该商品的购买才能分享(退款 / 违约 / 争议订单不算)', completed_orders: 0 });
171
184
  // 优先复用现有 active shareable
172
185
  const existing = await dbOne(`SELECT id, owner_code FROM shareables WHERE owner_id = ? AND related_product_id = ? AND status = 'active' LIMIT 1`, [user.id, productId]);
173
186
  if (existing) {
@@ -35,7 +35,7 @@ export function registerProfilePlacementRoutes(app, deps) {
35
35
  return void res.json({ error: '不能挂靠到自己' });
36
36
  const u = await dbOne("SELECT placement_id, left_child_id, right_child_id FROM users WHERE id = ?", [user.id]);
37
37
  if (u?.placement_id)
38
- return void res.json({ error: '你已在双轨树中(永久第一触点,不可改)' });
38
+ return void res.json({ error: '你已在放置树中(永久第一触点,不可改)' });
39
39
  if (u?.left_child_id || u?.right_child_id)
40
40
  return void res.json({ error: '你已有下线,不可补绑(防破坏树结构)' });
41
41
  const inviter = await dbOne("SELECT id, placement_path FROM users WHERE id = ? AND id NOT IN ('sys_protocol', ?)", [resolvedInviterId, internalAuditorId]);
@@ -1,6 +1,7 @@
1
1
  import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
2
3
  export function registerPromoterRoutes(app, deps) {
3
- const { db, auth, isAllowedSponsor } = deps;
4
+ const { db, auth, isAllowedSponsor, participationRecordingActive } = deps;
4
5
  void db; // RFC-016: 本文件已全量走异步 seam;db 仍在 deps 由调用方注入,此处不直接使用
5
6
  app.get('/api/promoter/dashboard', async (req, res) => {
6
7
  const user = auth(req, res);
@@ -41,21 +42,9 @@ export function registerPromoterRoutes(app, deps) {
41
42
  const leftChildName = myUser?.left_child_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [myUser.left_child_id]))?.name : null;
42
43
  const rightChildName = myUser?.right_child_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [myUser.right_child_id]))?.name : null;
43
44
  const myPlacementName = myUser?.placement_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [myUser.placement_id]))?.name : null;
44
- const scoreAgg = (await dbOne(`
45
- SELECT
46
- COALESCE(SUM(CASE WHEN settled_at IS NULL THEN score ELSE 0 END),0) as pending_score,
47
- COALESCE(SUM(CASE WHEN settled_at IS NOT NULL THEN waz_amount ELSE 0 END),0) as settled_waz,
48
- COUNT(*) as total_hits
49
- FROM binary_score_records WHERE user_id = ?
50
- `, [userId]));
51
- const recentBinary = await dbAll(`
52
- SELECT id, tier, score, settled_at, waz_amount, created_at
53
- FROM binary_score_records WHERE user_id = ?
54
- ORDER BY created_at DESC LIMIT 10
55
- `, [userId]);
56
- const tiers = await dbAll("SELECT tier, pv_threshold, score_per_hit FROM binary_tier_config WHERE active=1 ORDER BY tier ASC");
45
+ // matching-rewards reads (score / recent matches / tier config) removed — engine excised (#401).
57
46
  const canL1Share = isAllowedSponsor(userId);
58
- const completedOrders = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
47
+ const completedOrders = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [userId])).n; // 真实成交,排除退款/违约
59
48
  const overrideRow = await dbOne("SELECT l1_share_override FROM users WHERE id = ?", [userId]);
60
49
  const shareableProducts = await dbAll(`
61
50
  SELECT p.id, p.title, p.price, p.category, p.commission_rate,
@@ -64,7 +53,7 @@ export function registerPromoterRoutes(app, deps) {
64
53
  JOIN orders o2 ON o2.id = cr.order_id
65
54
  WHERE cr.beneficiary_id = ? AND o2.product_id = p.id), 0) as my_earned
66
55
  FROM products p
67
- WHERE p.id IN (SELECT DISTINCT product_id FROM orders WHERE buyer_id = ? AND status = 'completed')
56
+ WHERE p.id IN (SELECT DISTINCT product_id FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')})
68
57
  AND p.commission_rate IS NOT NULL AND p.commission_rate > 0
69
58
  AND p.status = 'active'
70
59
  ORDER BY my_earned DESC, total_sales DESC LIMIT 20
@@ -77,21 +66,15 @@ export function registerPromoterRoutes(app, deps) {
77
66
  SELECT COALESCE(SUM(amount),0) as total FROM commission_records
78
67
  WHERE beneficiary_id = ? AND created_at >= datetime('now','-60 days')
79
68
  AND created_at < datetime('now','-30 days')
80
- `, [userId])).total;
81
- const wazLast30 = (await dbOne(`
82
- SELECT COALESCE(SUM(waz_amount),0) as total FROM binary_score_records
83
- WHERE user_id = ? AND settled_at >= datetime('now','-30 days')
84
69
  `, [userId])).total;
85
70
  const projection = {
86
71
  last_30_commission: earnedLast30,
87
72
  prev_30_commission: earnedPrev30,
88
- last_30_atomic_waz: wazLast30,
89
73
  growth_rate: earnedPrev30 > 0 ? earnedLast30 / earnedPrev30 - 1 : null,
90
- next_30_estimate: earnedLast30 + wazLast30,
74
+ next_30_estimate: earnedLast30,
91
75
  };
92
76
  const insights = [];
93
- // pre-public de-MLM:移除弱腿 / pairing / PV-tier 经营建议 —— PV 对碰为 pre-launch、未对用户启用,
94
- // 不在用户面 surface 营销主推弱腿 / pairing 公式 / PV-tier 进度等玩法。位置 / PV 仅为参与记录,非收益路径。
77
+ // 匹配奖励引擎已切除(#401):不展示任何奖励经营建议;位置 / PV 仅为参与记录,非收益路径。
95
78
  const lastInvite = (await dbOne(`SELECT MAX(created_at) as t FROM users WHERE sponsor_id = ?`, [userId]));
96
79
  if (lastInvite.t) {
97
80
  const days = Math.floor((Date.now() - new Date(lastInvite.t).getTime()) / 86400_000);
@@ -167,17 +150,15 @@ export function registerPromoterRoutes(app, deps) {
167
150
  shareable_products: shareableProducts,
168
151
  projection,
169
152
  insights,
153
+ gates: { participation_recording_active: participationRecordingActive() },
154
+ // Neutral participation record only — placement position + per-leg PV. No rewards (matching engine excised #401).
155
+ // (response keys `atomic` / `binary_tree` kept as the placement structure's stable shape — frontend reads them)
170
156
  atomic: {
171
- left_invite_url: codeForLink ? `${host}/i/${codeForLink}-L` : null,
172
- right_invite_url: codeForLink ? `${host}/i/${codeForLink}-R` : null,
173
157
  total_left_pv: Number(myUser?.total_left_pv ?? 0),
174
158
  total_right_pv: Number(myUser?.total_right_pv ?? 0),
175
159
  left_child: myUser?.left_child_id ? { id: myUser.left_child_id, name: leftChildName } : null,
176
160
  right_child: myUser?.right_child_id ? { id: myUser.right_child_id, name: rightChildName } : null,
177
161
  my_placement: myUser?.placement_id ? { id: myUser.placement_id, name: myPlacementName, side: myUser.placement_side } : null,
178
- score: scoreAgg,
179
- recent_binary: recentBinary,
180
- tier_config: tiers,
181
162
  binary_tree: binaryTree,
182
163
  },
183
164
  });
@@ -1,4 +1,5 @@
1
1
  import { listBuildTasksWithAgentMetadata, getBuildTaskWithAgentMetadata, validateTaskFilters, withContributionReadEnvelope } from '../../layer2-business/L2-9-contribution/build-task-read.js';
2
+ import { caseIdForTask } from '../../layer2-business/L2-9-contribution/task-proposal-draft.js';
2
3
  export function registerPublicBuildTasksRoutes(app, deps) {
3
4
  const { db, errorRes } = deps;
4
5
  app.get('/api/public/build-tasks', (req, res) => {
@@ -14,6 +15,9 @@ export function registerPublicBuildTasksRoutes(app, deps) {
14
15
  const task = getBuildTaskWithAgentMetadata(db, String(req.params.id), 'public');
15
16
  if (!task)
16
17
  return void errorRes(res, 404, 'NOT_FOUND', '任务不存在');
17
- res.json(withContributionReadEnvelope({ task }));
18
+ // case_id threads proposal → task → PR (= source proposal id if converted from a proposal, else the task id),
19
+ // so the proposer, the contributor, and the PR all quote one id. (Helper lives in the store — keeps this
20
+ // route off the RFC-016 raw-db seam.)
21
+ res.json(withContributionReadEnvelope({ task: { ...task, case_id: caseIdForTask(db, String(req.params.id)) } }));
18
22
  });
19
23
  }
@@ -66,12 +66,10 @@ export function registerPublicUtilsRoutes(app, deps) {
66
66
  });
67
67
  app.get('/api/system-flags', async (_req, res) => {
68
68
  const requireRef = (await dbOne("SELECT value FROM system_state WHERE key='require_ref_to_register'"))?.value === '1';
69
- const inviteRotation = (await dbOne("SELECT value FROM system_state WHERE key='invite_rotation_enabled'"))?.value === '1';
70
69
  // #1049 Turnstile 公钥(若启用),前端注册表单 widget 用
71
70
  const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY || null;
72
71
  res.json({
73
72
  require_ref_to_register: requireRef,
74
- invite_rotation_enabled: inviteRotation,
75
73
  turnstile_site_key: turnstileSiteKey,
76
74
  });
77
75
  });
@@ -134,13 +132,12 @@ export function registerPublicUtilsRoutes(app, deps) {
134
132
  },
135
133
  // 公开披露文档(#1050) — 协议层"钱怎么流"的源真理(协议外可读)
136
134
  disclosures: {
137
- // 源码仓库 launch 前私有,下列 github 链接可能 404(launch 时开),不是死项目;机器可读 spec 已全在 /.well-known/*。
138
- source_status: 'repo private until W8 public launch — github.com links below may 404 until then (they open at launch); the full spec is already public via /.well-known/*.',
139
- economic_model: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/ECONOMIC-MODEL.md',
140
- mlm_compliance: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/MLM-COMPLIANCE.md',
141
- agent_governance: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/AGENT-GOVERNANCE.md',
142
- protocol_compatibility: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/PROTOCOL-COMPATIBILITY-AUDIT-2026-05-30.md',
143
- changelog: 'https://github.com/seasonsagents-art/webaz/blob/main/CHANGELOG.md',
135
+ // 源码仓库已公开(github.com/webaz-protocol/webaz);机器可读 spec 也全在 /.well-known/*。
136
+ source_status: 'repo is public (github.com/webaz-protocol/webaz); the full spec is also available via /.well-known/*.',
137
+ economic_model: 'https://github.com/webaz-protocol/webaz/blob/main/docs/ECONOMIC-MODEL.md',
138
+ mlm_compliance: 'https://github.com/webaz-protocol/webaz/blob/main/docs/PARTICIPATION-ATTRIBUTION-COMPLIANCE.md',
139
+ agent_governance: 'https://github.com/webaz-protocol/webaz/blob/main/docs/AGENT-GOVERNANCE.md',
140
+ changelog: 'https://github.com/webaz-protocol/webaz/blob/main/CHANGELOG.md',
144
141
  // RFC-011 §②:agent 可读能力矩阵(写边界 action-scope + 敏感读 scope),live doc=code
145
142
  capability_matrix: 'https://webaz.xyz/.well-known/webaz-capabilities.json',
146
143
  },
@@ -457,9 +454,9 @@ export function registerPublicUtilsRoutes(app, deps) {
457
454
  eligibility,
458
455
  quiz_pass_score: quizPassScore,
459
456
  spec_urls: {
460
- onboarding: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/GOVERNANCE-ONBOARDING.md',
461
- playbook: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/ARBITRATION-PLAYBOOK.md',
462
- leaderboard: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/GOVERNANCE-LEADERBOARD-SPEC.md',
457
+ onboarding: 'https://github.com/webaz-protocol/webaz/blob/main/docs/GOVERNANCE-ONBOARDING.md',
458
+ playbook: 'https://github.com/webaz-protocol/webaz/blob/main/docs/ARBITRATION-PLAYBOOK.md',
459
+ leaderboard: 'https://github.com/webaz-protocol/webaz/blob/main/docs/GOVERNANCE-LEADERBOARD-SPEC.md',
463
460
  },
464
461
  });
465
462
  }
@@ -1,7 +1,8 @@
1
- import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
1
+ import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
2
3
  export function registerReferralRoutes(app, deps) {
3
4
  // db 已全量走 RFC-016 异步 seam(dbOne/dbAll/dbRun),不再直接用 deps.db
4
- const { auth, requireProtocolAdmin, logAdminAction, issueInviteSlot, inviteRotationLookup } = deps;
5
+ const { auth } = deps;
5
6
  // B-1: 个人邀请 dashboard
6
7
  app.get('/api/referral/me', async (req, res) => {
7
8
  const user = auth(req, res);
@@ -36,28 +37,6 @@ export function registerReferralRoutes(app, deps) {
36
37
  },
37
38
  });
38
39
  });
39
- // 公开邀请码轮询(开关 ON 时)
40
- app.post('/api/invite/rotate', async (_req, res) => {
41
- const enabled = (await dbOne("SELECT value FROM system_state WHERE key='invite_rotation_enabled'"))?.value === '1';
42
- if (!enabled)
43
- return void res.status(403).json({ error: '邀请码获取暂未开放', enabled: false });
44
- const slot = issueInviteSlot();
45
- const u = inviteRotationLookup(slot);
46
- if (!u)
47
- return void res.status(503).json({ error: `轮询用户未就绪,请联系管理员`, enabled: true });
48
- res.json({ enabled: true, code: u.code });
49
- });
50
- // protocol 开关
51
- app.post('/api/admin/invite-rotation/toggle', async (req, res) => {
52
- const admin = requireProtocolAdmin(req, res);
53
- if (!admin)
54
- return;
55
- const { enabled } = req.body;
56
- const v = enabled ? '1' : '0';
57
- await dbRun("INSERT OR REPLACE INTO system_state (key, value) VALUES ('invite_rotation_enabled', ?)", [v]);
58
- logAdminAction(admin.id, 'invite_rotation_toggle', 'system', 'invite_rotation_enabled', { value: v });
59
- res.json({ success: true, enabled: !!enabled });
60
- });
61
40
  // RFC-003 #1122: 生成商品分享链接(把 MCP webaz_share_link 的本地计算搬到服务端,
62
41
  // 让 MCP NETWORK 模式可代理)。RFC-002 §3.5 valuation-layer gate:需 rewards opt-in。
63
42
  // pre-public 去左右码:不再接受/返回 side,放置侧别由注册时系统自动决定。
@@ -77,7 +56,7 @@ export function registerReferralRoutes(app, deps) {
77
56
  };
78
57
  const minOrders = await getParam('rewards_opt_in.min_completed_orders', 1);
79
58
  const requirePasskey = await getParam('rewards_opt_in.require_passkey', 1);
80
- const totalCompleted = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
59
+ const totalCompleted = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [userId])).n; // 真实成交,排除退款/违约
81
60
  const passkeyCount = (await dbOne("SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?", [userId])).n;
82
61
  const missing = [];
83
62
  if (totalCompleted < minOrders)
@@ -101,7 +80,7 @@ export function registerReferralRoutes(app, deps) {
101
80
  if (!product)
102
81
  return void res.status(404).json({ error: '商品不存在或已下架', error_code: 'PRODUCT_NOT_FOUND' });
103
82
  // pre-public 去左右码:分享链接不再计算/携带 side(放置侧别由注册时系统自动决定)
104
- const completed = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
83
+ const completed = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [userId])).n; // 真实成交,排除退款/违约
105
84
  const override = (await dbOne("SELECT l1_share_override FROM users WHERE id = ?", [userId]))?.l1_share_override ?? 0;
106
85
  const canL1 = override === 1 || (override === 0 && completed > 0);
107
86
  const rate = Number(product.commission_rate ?? 0);
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
3
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
3
4
  function sha256_hex(s) {
4
5
  return createHash('sha256').update(s).digest('hex');
5
6
  }
@@ -26,7 +27,7 @@ export function registerRewardsApplyRoutes(app, deps) {
26
27
  state = 'auto_downgraded';
27
28
  else
28
29
  state = 'never_activated';
29
- const completedOrders = (await dbOne("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
30
+ const completedOrders = (await dbOne(`SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [userId])).n; // 真实成交,排除退款/违约
30
31
  const passkeyCount = (await dbOne("SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?", [userId])).n;
31
32
  const minOrders = Number(getProtocolParam('rewards_opt_in.min_completed_orders', 1));
32
33
  const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
@@ -103,7 +104,7 @@ export function registerRewardsApplyRoutes(app, deps) {
103
104
  }
104
105
  // 5. Pre-conditions (re-check inside server)
105
106
  const minOrders = Number(getProtocolParam('rewards_opt_in.min_completed_orders', 1));
106
- const completedOrders = (await dbOne("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
107
+ const completedOrders = (await dbOne(`SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [userId])).n; // 真实成交,排除退款/违约
107
108
  if (completedOrders < minOrders)
108
109
  return void errorRes(res, 403, 'INSUFFICIENT_ORDERS', `需 ${minOrders} 笔已完成订单,目前 ${completedOrders}`);
109
110
  const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
@@ -158,7 +158,7 @@ export function registerShareRedirectsRoutes(app, deps) {
158
158
  // 邀请短链 /i/CODE — invite-code ONLY (permanent_code, 兼容旧的 -L/-R 后缀). usr_xxx / @handle / 裸 handle
159
159
  // 一律 404(不再做 handle 解析)。pre-public 去左右码:/i/CODE 与旧 /i/CODE-L、/i/CODE-R 一律
160
160
  // 规范化重定向到 /?ref=CODE(丢弃 side;放置侧别由注册时系统自动决定),旧链接/二维码仍可用。
161
- // 不受 invite_rotation_enabled 影响:已有用户分享出的 /i/CODE 链接和二维码必须始终可用。
161
+ // 不受注册门控开关影响:已有用户分享出的 /i/CODE 链接和二维码必须始终可用。
162
162
  app.get('/i/:code', (req, res) => {
163
163
  const ref = resolveInviteCodeRef(String(req.params.code || ''));
164
164
  if (!ref)
@@ -1,4 +1,5 @@
1
1
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
2
3
  export function registerShareablesInteractionsRoutes(app, deps) {
3
4
  const { db, auth, generateId, rateLimitOk, piiSanitize, detectFraud, commentBlocklistHit, llmModerateComment, parseMentions, notifyMentions } = deps;
4
5
  app.post('/api/shareables/:id/click', async (req, res) => {
@@ -21,7 +22,7 @@ export function registerShareablesInteractionsRoutes(app, deps) {
21
22
  if (sh.owner_id === user.id)
22
23
  return void res.json({ error: '不能给自己点赞' });
23
24
  // P1 Sybil 软门槛:至少完成过 1 笔订单(不限购买该商品,只需活跃用户)
24
- const completed = (await dbOne("SELECT COUNT(1) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [user.id])).n;
25
+ const completed = (await dbOne(`SELECT COUNT(1) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [user.id])).n; // 真实成交,排除退款/违约
25
26
  if (completed < 1)
26
27
  return void res.json({ error: '完成首笔购买后才能点赞(防止刷赞)' });
27
28
  // P0 fix:SELECT existing 进 transaction
@@ -1,15 +1,25 @@
1
- import { validateProposalInput, insertTaskProposal, listTaskProposals, reviewTaskProposal } from '../../layer2-business/L2-9-contribution/task-proposal-store.js';
1
+ import { validateProposalInput, insertTaskProposal, listTaskProposals, listMyProposals, reviewTaskProposal } from '../../layer2-business/L2-9-contribution/task-proposal-store.js';
2
2
  import { withUncommittedValueBoundary } from '../../layer2-business/L2-9-contribution/contribution-display-envelope.js';
3
3
  import { getCanonicalContributionTarget } from '../../layer2-business/L2-9-contribution/canonical-contribution-target.js';
4
- import { createDraftFromProposal, listDraftBuildTasks, publishDraftBuildTask } from '../../layer2-business/L2-9-contribution/task-proposal-draft.js';
4
+ import { createDraftFromProposal, listDraftBuildTasks, getDraftBuildTaskDetail, publishDraftBuildTask, discardDraft, withdrawPublishedTask } from '../../layer2-business/L2-9-contribution/task-proposal-draft.js';
5
5
  import { recommendForProposal, insertAiSuggestion, listAiSuggestions, getProposalLite } from '../../layer2-business/L2-9-contribution/task-proposal-ai-store.js';
6
6
  const AI_NOTICE = 'AI suggestion — assistant only, NOT a decision. A human maintainer must explicitly create / publish / reject the formal task. AI never auto-publishes, auto-rejects, hides proposals, or assigns reward / credit.';
7
7
  const PROPOSAL_NOTICE = 'A task proposal is a SUGGESTION in the maintainer review inbox. It is NOT a contribution fact, formal participation, or any reward / payout / score, and it never appears on the public task board until a maintainer reviews and (manually) converts it. source_ref is a reference only; the canonical contribution target is fixed by trusted config.';
8
8
  function withProposalEnvelope(payload) {
9
9
  return withUncommittedValueBoundary({ ...payload, proposal_notice: PROPOSAL_NOTICE, canonical_contribution_target: getCanonicalContributionTarget() });
10
10
  }
11
+ // Agent-readable next-step hint per status (the channel is agent-native both ways).
12
+ function nextActionFor(status) {
13
+ switch (status) {
14
+ case 'new': return 'Awaiting maintainer review — no action needed.';
15
+ case 'needs_info': return 'Maintainer needs more detail. Submit an updated proposal (webaz_feedback type=proposal) referencing this id; see public_reply for what is missing.';
16
+ case 'rejected': return 'Not converted to a task. See public_reply for the reason; you may submit a revised proposal.';
17
+ case 'converted': return 'Converted to a task. See converted_ref for the linked task / PR / decision.';
18
+ default: return 'No action.';
19
+ }
20
+ }
11
21
  export function registerTaskProposalsRoutes(app, deps) {
12
- const { db, errorRes, requireSupportAdmin, rateLimitOk } = deps;
22
+ const { db, errorRes, requireSupportAdmin, rateLimitOk, auth, resolveUser } = deps;
13
23
  // public submit — anonymous; proposer_account_id is never taken from the body (anti-spoof).
14
24
  app.post('/api/public/task-proposals', (req, res) => {
15
25
  // anti-flood: per-IP rate limit (counts every attempt, before validation) then a recent-window dedup.
@@ -19,10 +29,23 @@ export function registerTaskProposalsRoutes(app, deps) {
19
29
  const v = validateProposalInput(req.body);
20
30
  if (!v.ok)
21
31
  return void errorRes(res, 400, v.code, v.message);
22
- const result = insertTaskProposal(db, v.input, null);
32
+ // Link to the submitter when authenticated (account id comes from the session, NEVER the body — anti-spoof);
33
+ // anonymous submit still works (account id null) — it just won't show up in "my proposals".
34
+ const submitter = resolveUser(req);
35
+ const accountId = submitter ? String(submitter.id) : null;
36
+ const result = insertTaskProposal(db, v.input, accountId);
23
37
  if ('duplicate' in result)
24
38
  return void errorRes(res, 409, 'DUPLICATE_PROPOSAL', '相同建议已在收件箱中,请勿重复提交', { existing_id: result.existing_id });
25
- res.json(withProposalEnvelope({ proposal: { id: result.id, status: result.status } }));
39
+ res.json(withProposalEnvelope({ proposal: { id: result.id, status: result.status }, linked_to_account: !!accountId }));
40
+ });
41
+ // proposer-facing read: the caller's OWN proposals + status + public_reply (agent-readable). No review_note; own rows only.
42
+ app.get('/api/me/task-proposals', (req, res) => {
43
+ const user = auth(req, res);
44
+ if (!user)
45
+ return;
46
+ // case_id threads the whole case (proposal → task → PR). For a proposal it is the proposal's own id.
47
+ const proposals = listMyProposals(db, String(user.id)).map(p => ({ ...p, case_id: p.id, next_action: nextActionFor(String(p.status)) }));
48
+ res.json(withProposalEnvelope({ proposals }));
26
49
  });
27
50
  // admin list (maintainer only)
28
51
  app.get('/api/admin/task-proposals', (req, res) => {
@@ -30,15 +53,17 @@ export function registerTaskProposalsRoutes(app, deps) {
30
53
  if (!admin)
31
54
  return;
32
55
  const status = typeof req.query.status === 'string' ? req.query.status : undefined;
33
- res.json(withProposalEnvelope({ proposals: listTaskProposals(db, { status }) }));
56
+ // case_id = the proposal's own id (the case originates at the proposal); shown in the management inbox.
57
+ const proposals = listTaskProposals(db, { status }).map(p => ({ ...p, case_id: p.id }));
58
+ res.json(withProposalEnvelope({ proposals }));
34
59
  });
35
60
  // admin review (maintainer only): needs_info | rejected | converted — no build_task is created here.
36
61
  app.post('/api/admin/task-proposals/:id/review', (req, res) => {
37
62
  const admin = requireSupportAdmin(req, res);
38
63
  if (!admin)
39
64
  return;
40
- const { status, note, converted_ref } = req.body ?? {};
41
- const result = reviewTaskProposal(db, String(req.params.id), admin.id, String(status), note, converted_ref);
65
+ const { status, note, converted_ref, public_reply } = req.body ?? {};
66
+ const result = reviewTaskProposal(db, String(req.params.id), admin.id, String(status), note, converted_ref, public_reply);
42
67
  if ('error' in result) {
43
68
  const code = result.code === 'NOT_FOUND' ? 404 : result.code === 'ALREADY_TERMINAL' ? 409 : 400;
44
69
  return void errorRes(res, code, result.code, result.error);
@@ -90,6 +115,11 @@ export function registerTaskProposalsRoutes(app, deps) {
90
115
  definitionOfDone: b.definition_of_done ?? null,
91
116
  expectedResults: b.expected_results ?? null,
92
117
  autoClaimable: b.auto_claimable === false ? false : undefined,
118
+ // optional real effort estimate (#34/#5); if omitted the draft is a 0–0 placeholder and publish is fail-closed.
119
+ estimatedDurationMinMinutes: typeof b.estimated_duration_min_minutes === 'number' ? b.estimated_duration_min_minutes : undefined,
120
+ estimatedDurationMaxMinutes: typeof b.estimated_duration_max_minutes === 'number' ? b.estimated_duration_max_minutes : undefined,
121
+ estimatedAgentBudget: b.estimated_agent_budget,
122
+ estimatedContextSize: b.estimated_context_size,
93
123
  riskLevel: b.risk_level, taskType: b.task_type, note: b.note ?? null,
94
124
  });
95
125
  if ('error' in r) {
@@ -105,12 +135,30 @@ export function registerTaskProposalsRoutes(app, deps) {
105
135
  return;
106
136
  res.json(withProposalEnvelope({ drafts: listDraftBuildTasks(db) }));
107
137
  });
138
+ // full stored body of ONE unpublished internal draft — for PRE-PUBLISH PREVIEW (publish against visible content).
139
+ app.get('/api/admin/build-task-drafts/:id', (req, res) => {
140
+ const admin = requireSupportAdmin(req, res);
141
+ if (!admin)
142
+ return;
143
+ const draft = getDraftBuildTaskDetail(db, String(req.params.id));
144
+ if (!draft)
145
+ return void errorRes(res, 404, 'NOT_FOUND', 'draft not found (or not an unpublished internal draft)');
146
+ res.json(withProposalEnvelope({ draft }));
147
+ });
108
148
  // PUBLISH a draft → public open task — explicit human/admin action; records the acting admin
109
149
  app.post('/api/admin/build-task-drafts/:id/publish', (req, res) => {
110
150
  const admin = requireSupportAdmin(req, res);
111
151
  if (!admin)
112
152
  return;
113
- const r = publishDraftBuildTask(db, String(req.params.id), admin.id);
153
+ const b = (req.body ?? {});
154
+ // a published task MUST carry a real effort estimate (#34/#5). The maintainer supplies it here when the
155
+ // draft is still a 0–0 placeholder; without it publishDraftBuildTask fails closed (DRAFT_ESTIMATE_REQUIRED).
156
+ const r = publishDraftBuildTask(db, String(req.params.id), admin.id, {
157
+ minMinutes: typeof b.estimated_duration_min_minutes === 'number' ? b.estimated_duration_min_minutes : undefined,
158
+ maxMinutes: typeof b.estimated_duration_max_minutes === 'number' ? b.estimated_duration_max_minutes : undefined,
159
+ budget: b.estimated_agent_budget,
160
+ contextSize: b.estimated_context_size,
161
+ });
114
162
  if ('error' in r) {
115
163
  const code = r.error_code === 'NOT_FOUND' ? 404
116
164
  : (r.error_code === 'PROPOSAL_REJECTED' || r.error_code === 'PROPOSAL_CONVERTED_ELSEWHERE') ? 409 : 400;
@@ -118,4 +166,32 @@ export function registerTaskProposalsRoutes(app, deps) {
118
166
  }
119
167
  res.json(withProposalEnvelope({ published: { task_id: r.task_id, published: true }, published_by: admin.id }));
120
168
  });
169
+ // DISCARD an unpublished internal draft (soft-delete → frees the proposal's draft slot; provenance retained).
170
+ // Fail-closed: refuses a published / claimed draft or an already-converted source proposal. Scope = discard only.
171
+ app.post('/api/admin/build-task-drafts/:id/discard', (req, res) => {
172
+ const admin = requireSupportAdmin(req, res);
173
+ if (!admin)
174
+ return;
175
+ const r = discardDraft(db, String(req.params.id), admin.id);
176
+ if ('error' in r) {
177
+ const code = r.error_code === 'NOT_FOUND' ? 404
178
+ : (r.error_code === 'ALREADY_PUBLISHED' || r.error_code === 'DRAFT_CLAIMED' || r.error_code === 'ALREADY_CONVERTED') ? 409 : 400;
179
+ return void errorRes(res, code, r.error_code, r.error);
180
+ }
181
+ res.json(withProposalEnvelope({ discarded: { task_id: r.task_id, status: 'discarded', already_discarded: !!r.already_discarded }, discarded_by: admin.id }));
182
+ });
183
+ // RECOVERY: withdraw an UNCLAIMED published task off the board + reopen its source proposal (so a corrected
184
+ // draft can be built). Fail-closed: refuses a claimed task or a non-published task. Soft-delete (provenance kept).
185
+ app.post('/api/admin/build-tasks/:id/withdraw', (req, res) => {
186
+ const admin = requireSupportAdmin(req, res);
187
+ if (!admin)
188
+ return;
189
+ const r = withdrawPublishedTask(db, String(req.params.id), admin.id);
190
+ if ('error' in r) {
191
+ const code = r.error_code === 'NOT_FOUND' ? 404
192
+ : (r.error_code === 'TASK_CLAIMED' || r.error_code === 'NOT_PUBLISHED') ? 409 : 400;
193
+ return void errorRes(res, code, r.error_code, r.error);
194
+ }
195
+ res.json(withProposalEnvelope({ withdrawn: { task_id: r.task_id, reopened_proposal_id: r.reopened_proposal_id }, withdrawn_by: admin.id }));
196
+ });
121
197
  }
@@ -62,8 +62,7 @@ export function registerUsersPublicRoutes(app, deps) {
62
62
  const rightChildName = u.right_child_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [u.right_child_id]))?.name : null;
63
63
  const leftPv = Number(u.total_left_pv ?? 0);
64
64
  const rightPv = Number(u.total_right_pv ?? 0);
65
- // pre-public de-MLM: PV 对碰为 pre-launch、未启用 —— public 端口不再暴露弱腿 / 对碰收益指标
66
- // (weak_leg_pv / pending_score / settled_waz / total_hits)。位置 + 左右区 PV 仅作为参与记录保留。
65
+ // 匹配奖励引擎已切除(#401):public 端口只保留位置 + 左右区 PV 作为参与记录,不暴露任何奖励指标。
67
66
  res.json({
68
67
  id: u.id,
69
68
  name: u.name,
@@ -263,13 +262,11 @@ export function registerUsersPublicRoutes(app, deps) {
263
262
  if (isOwner) {
264
263
  const w = await dbOne('SELECT balance, earned FROM wallets WHERE user_id = ?', [me.id]);
265
264
  const pv = await dbOne("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?", [me.id]);
266
- const score = (await dbOne("SELECT COALESCE(SUM(score),0) as s FROM binary_score_records WHERE user_id = ? AND settled_at IS NULL", [me.id])).s;
267
265
  privateStats = {
268
266
  wallet_balance: Number(w?.balance ?? 0),
269
267
  wallet_earned: Number(w?.earned ?? 0),
270
268
  total_left_pv: Number(pv?.total_left_pv ?? 0),
271
269
  total_right_pv: Number(pv?.total_right_pv ?? 0),
272
- pending_score: Number(score),
273
270
  };
274
271
  }
275
272
  // D2 信誉徽章墙
@@ -175,26 +175,14 @@ export function registerWalletReadRoutes(app, deps) {
175
175
  if (commMap[key])
176
176
  commMap[key] = { count: r.cnt, total: Number(r.total.toFixed(2)) };
177
177
  }
178
- const binary = (await dbOne(`
179
- SELECT
180
- COUNT(CASE WHEN settled_at IS NOT NULL THEN 1 END) as settled_cnt,
181
- COALESCE(SUM(CASE WHEN settled_at IS NOT NULL THEN waz_amount END), 0) as settled_waz,
182
- COALESCE(SUM(CASE WHEN settled_at IS NULL THEN score END), 0) as pending_score
183
- FROM binary_score_records WHERE user_id = ?
184
- `, [user.id]));
178
+ // matching-rewards income removed — engine excised (#401). Income = affiliate commission (real sales) + own sales.
185
179
  const sales = (await dbOne(`
186
180
  SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount),0) as total
187
181
  FROM orders WHERE seller_id = ? AND status = 'completed'
188
182
  `, [user.id]));
189
- const totalIncome = commMap.l1.total + commMap.l2.total + commMap.l3.total +
190
- Number(binary.settled_waz) + Number(sales.total);
183
+ const totalIncome = commMap.l1.total + commMap.l2.total + commMap.l3.total + Number(sales.total);
191
184
  res.json({
192
185
  commissions: commMap,
193
- binary: {
194
- settled_count: binary.settled_cnt,
195
- settled_waz: Number(Number(binary.settled_waz).toFixed(2)),
196
- pending_score: Number(Number(binary.pending_score).toFixed(2)),
197
- },
198
186
  sales: { count: sales.cnt, total: Number(Number(sales.total).toFixed(2)) },
199
187
  total_income: Number(totalIncome.toFixed(2)),
200
188
  });