@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
@@ -89,12 +89,35 @@ export function registerAdminUsersQueryRoutes(app, deps) {
89
89
  return void res.status(400).json({ error: 'action 必须 suspend/unsuspend' });
90
90
  const reasonStr = action === 'suspend' ? (reason ? String(reason).slice(0, 200) : 'admin 批量暂停') : null;
91
91
  const results = [];
92
+ // Per-uid authorization boundary (res-free, so one bad uid never aborts the whole batch). Mirrors
93
+ // adminCanOperateOn but stricter for admin targets: an admin target is ROOT-only regardless of scope.
94
+ const actingRoot = isRootAdmin(admin);
95
+ const actingScope = admin.admin_scope || 'global';
96
+ const canOperate = (t) => {
97
+ if (t.admin_type)
98
+ return actingRoot ? { ok: true } : { ok: false, reason: '仅 root 可操作 admin 账号' };
99
+ if (actingRoot || actingScope === 'global')
100
+ return { ok: true };
101
+ if (t.region && t.region !== actingScope)
102
+ return { ok: false, reason: `跨区用户(${t.region})仅本区/全局 admin 可操作` };
103
+ return { ok: true };
104
+ };
92
105
  for (const uid of user_ids) {
93
106
  try {
94
107
  if (uid === 'sys_protocol' || uid === admin.id) {
95
108
  results.push({ user_id: uid, status: 'skipped', reason: '保留账户或自己' });
96
109
  continue;
97
110
  }
111
+ const target = await dbOne('SELECT admin_type, region FROM users WHERE id = ?', [uid]);
112
+ if (!target) {
113
+ results.push({ user_id: uid, status: 'skipped', reason: '用户不存在' });
114
+ continue;
115
+ }
116
+ const gate = canOperate(target);
117
+ if (!gate.ok) {
118
+ results.push({ user_id: uid, status: 'skipped', reason: gate.reason });
119
+ continue;
120
+ }
98
121
  if (action === 'suspend') {
99
122
  await dbRun(`INSERT INTO user_moderation (user_id, suspended, reason, suspended_by, suspended_at)
100
123
  VALUES (?, 1, ?, ?, datetime('now'))
@@ -386,7 +409,6 @@ export function registerAdminUsersQueryRoutes(app, deps) {
386
409
  reputation: user.reputation,
387
410
  failed_attempts: user.failed_attempts ?? 0,
388
411
  locked_until: user.locked_until,
389
- mgmt_bonus_eligible: !!user.mgmt_bonus_eligible,
390
412
  l1_share_override: Number(user.l1_share_override ?? 0),
391
413
  can_l1_share: isAllowedSponsor(user.id),
392
414
  },
@@ -58,7 +58,7 @@ export function registerAdminWalletOpsRoutes(app, deps) {
58
58
  res.json(list);
59
59
  });
60
60
  app.post('/api/admin/withdrawals/:id/approve', async (req, res) => {
61
- // 双轨过渡鉴权:优先认登录的 protocol-admin(Bearer)→ 记其真实 admin id;
61
+ // 双重受理过渡鉴权:优先认登录的 protocol-admin(Bearer)→ 记其真实 admin id;
62
62
  // 否则回落到共享 ADMIN_KEY(adminAuth,既有运维路径,行为不变)→ actor 记中性标记 'admin_key'。
63
63
  // 仅认 protocol 权限的 admin;非 protocol 的 Bearer 不放行(soft 解析返回 null),不扩大访问面,只精确归属。
64
64
  let actorId = 'admin_key';
@@ -0,0 +1,255 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
3
+ import { initAgentDelegationGrantsSchema, initAgentPairingSchema, initAgentGrantAuthLogSchema } from '../../runtime/webaz-schema-helpers.js';
4
+ import { validateRequestedCapabilities, clampTtlSeconds, grantIsActive } from '../../runtime/agent-grant-scopes.js';
5
+ import { generateUserCode, verifyPkceS256, clampPairingTtlSeconds, pairingApprovable, pairingRetrievable } from '../../runtime/agent-pairing.js';
6
+ import { verifyGrantToken } from '../../runtime/agent-grant-verifier.js';
7
+ // Bounds on a pairing request (anti-bloat for the anonymous start endpoint).
8
+ const MAX_CAPABILITIES = 12;
9
+ const MAX_CONSTRAINTS_JSON = 2000;
10
+ function safeParseCaps(json) {
11
+ try {
12
+ return JSON.parse(String(json));
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ /** Server-generated consent view for a pairing — canonical scope labels only, no secrets. */
19
+ function consentView(p) {
20
+ return {
21
+ pairing_id: p.pairing_id,
22
+ agent_label: p.agent_label || null,
23
+ reason: p.reason || null, // agent-supplied free text (display only)
24
+ capabilities: safeParseCaps(p.capabilities), // server-validated safe scopes
25
+ status: p.status,
26
+ expires_at: p.expires_at,
27
+ notice: 'Approving issues a scoped, short-lived, revocable delegation grant — NOT your api_key, NOT your funds. Safe (read/draft) scopes only; it can never move money, vote, arbitrate, or change keys.',
28
+ };
29
+ }
30
+ export function registerAgentGrantsRoutes(app, deps) {
31
+ const { db, auth, generateId, rateLimitOk } = deps;
32
+ // PWA runtime self-init (MCP gets the tables via applyWebazRuntimeSchema). Idempotent.
33
+ initAgentDelegationGrantsSchema(db);
34
+ initAgentPairingSchema(db);
35
+ initAgentGrantAuthLogSchema(db);
36
+ // ─────────────────────────── RFC-020 PR-C2a: opt-in grant-scope enforcement ───────────────────────────
37
+ // EXPLICIT, per-route, per-SAFE-scope. NOT global auth — a gtk_* token is accepted ONLY by routes that
38
+ // deliberately mount requireAgentGrantScope(scope); auth()/api_key is untouched and never accepts gtk_*.
39
+ // Risk / never-delegable scopes can never pass (the verifier hard-fails non-safe required scopes).
40
+ const requireAgentGrantScope = (scope) => async (req, res, next) => {
41
+ // Anti-abuse: throttle the grant-consumption path BEFORE any DB work (parity with pair/start).
42
+ // Bounds both anonymous probing and valid-grant spam, and caps audit-log growth.
43
+ if (!rateLimitOk(`agent_grant:${req.ip || 'anon'}`, 30, 60_000)) {
44
+ return void res.status(429).json({ error: 'too_many_grant_requests', error_code: 'GRANT_RATE_LIMITED', retry_after_s: 60 });
45
+ }
46
+ const bearer = (req.header('authorization') || '').replace(/^Bearer\s+/i, '');
47
+ const presentedGrant = bearer.startsWith('gtk_'); // a request that presents no grant bearer isn't "grant-authorized"
48
+ const r = await verifyGrantToken(bearer, scope);
49
+ // Append-only audit (RFC-020 §3.7 + invariant: every grant-authorized request is audited). Only audit
50
+ // requests that actually presented a grant bearer — a no-token request is pure noise (and an unauth
51
+ // bloat vector), not a grant-authorized request.
52
+ let audited = false;
53
+ if (presentedGrant) {
54
+ try {
55
+ await dbRun('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)', [r.ok ? r.principal.grant_id : (r.grant_id ?? null), r.ok ? r.principal.human_id : (r.human_id ?? null), scope, r.ok ? 'allow' : 'deny', r.ok ? null : r.error_code]);
56
+ audited = true;
57
+ }
58
+ catch (e) {
59
+ console.error('[agent-grant] audit write failed:', e.message);
60
+ }
61
+ }
62
+ // Deny path: return the denial regardless of audit (no access is granted, so nothing to fail closed on).
63
+ if (!r.ok)
64
+ return void res.status(r.status).json({ error: r.error, error_code: r.error_code });
65
+ // Success path: FAIL CLOSED if the authorization could not be audited — a grant-authorized request
66
+ // must never proceed unaudited (RFC-020 invariant). Better to deny (503, retryable) than act unaccountably.
67
+ if (!audited)
68
+ return void res.status(503).json({ error: 'authorization audit unavailable; refusing to proceed unaudited', error_code: 'GRANT_AUDIT_FAILED' });
69
+ req.agentGrant = r.principal;
70
+ next();
71
+ };
72
+ // Vertical slice (zero-risk): grant principal introspection. Proves the verifier + opt-in middleware
73
+ // end-to-end on a brand-new read-only endpoint that touches NO existing route and NO money path.
74
+ app.get('/api/agent-grants/whoami', requireAgentGrantScope('read_public'), (req, res) => {
75
+ const p = req.agentGrant;
76
+ res.json({ grant: p, note: 'Authorized via delegation grant (safe scope read_public). This is a grant principal, not a human session.' });
77
+ });
78
+ // ─────────────────────────── RFC-020 PR-C1: pairing (device-flow + PKCE) ───────────────────────────
79
+ // C1 = pairing + credential delivery ONLY. No grant is consumed by any tool here (that is PR-C2).
80
+ // (pair 1) Agent starts a pairing — UNAUTHENTICATED (agent has no credential yet). Safe scopes only.
81
+ app.post('/api/agent-grants/pair/start', async (req, res) => {
82
+ // Rate-limit the anonymous write FIRST (anti-bloat: no DB row unless under the cap).
83
+ if (!rateLimitOk(`agent_pair_start:${req.ip || 'anon'}`, 10, 60_000)) {
84
+ return void res.status(429).json({ error: 'too_many_pairing_starts', retry_after_s: 60 });
85
+ }
86
+ const body = (req.body || {});
87
+ const codeChallenge = typeof body.code_challenge === 'string' ? body.code_challenge : '';
88
+ if (!codeChallenge || codeChallenge.length < 32 || codeChallenge.length > 256)
89
+ return void res.status(400).json({ error: 'code_challenge required (PKCE S256)' });
90
+ const caps = Array.isArray(body.capabilities) ? body.capabilities : [];
91
+ if (caps.length > MAX_CAPABILITIES)
92
+ return void res.status(400).json({ error: 'too_many_capabilities', max: MAX_CAPABILITIES });
93
+ const v = validateRequestedCapabilities(caps);
94
+ if (!v.ok)
95
+ return void res.status(403).json({ error: 'pairing_rejected', rejected: v.rejected }); // risk + never-delegable hard-reject
96
+ const pairingId = generateId('par');
97
+ const userCode = generateUserCode();
98
+ const ttl = clampPairingTtlSeconds(body.ttl_seconds);
99
+ const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
100
+ const label = typeof body.agent_label === 'string' ? body.agent_label.slice(0, 120) : null;
101
+ const reason = typeof body.reason === 'string' ? body.reason.slice(0, 280) : null; // agent free text only
102
+ const pubkey = typeof body.agent_pubkey === 'string' ? body.agent_pubkey.slice(0, 1000) : null; // RESERVED (PoP), not verified in C1
103
+ const capsJson = JSON.stringify(v.safe.map(c => ({ capability: c, constraints: (caps.find(x => x?.capability === c)?.constraints) || {} })));
104
+ if (capsJson.length > MAX_CONSTRAINTS_JSON)
105
+ return void res.status(400).json({ error: 'capabilities_too_large', max_bytes: MAX_CONSTRAINTS_JSON });
106
+ await dbRun('INSERT INTO agent_pairing_sessions (pairing_id, user_code, code_challenge, agent_label, agent_pubkey, reason, capabilities, status, expires_at) VALUES (?,?,?,?,?,?,?,?,?)', [pairingId, userCode, codeChallenge, label, pubkey, reason, capsJson, 'pending', expiresAt]);
107
+ res.status(201).json({
108
+ pairing_id: pairingId,
109
+ user_code: userCode,
110
+ approve_url: `/#pair?code=${userCode}`,
111
+ expires_at: expiresAt,
112
+ note: 'Ask the human to open approve_url at webaz.xyz (logged in) and approve. Then retrieve the credential with the PKCE verifier.',
113
+ });
114
+ });
115
+ // (pair 2) Human reviews the server-generated consent — human-authenticated.
116
+ app.get('/api/agent-grants/pair/:user_code', async (req, res) => {
117
+ const user = auth(req, res);
118
+ if (!user)
119
+ return;
120
+ const p = await dbOne('SELECT * FROM agent_pairing_sessions WHERE user_code = ?', [req.params.user_code]);
121
+ if (!p)
122
+ return void res.status(404).json({ error: 'pairing_not_found' });
123
+ if (!pairingApprovable(p, new Date().toISOString()))
124
+ return void res.status(409).json({ error: 'pairing_not_pending_or_expired', status: p.status });
125
+ res.json({ consent: consentView(p) });
126
+ });
127
+ // (pair 3) Human approves — human-authenticated. Issues the grant (token_hash filled at retrieve).
128
+ app.post('/api/agent-grants/pair/:user_code/approve', async (req, res) => {
129
+ const user = auth(req, res);
130
+ if (!user)
131
+ return;
132
+ const now = new Date().toISOString();
133
+ const p = await dbOne('SELECT * FROM agent_pairing_sessions WHERE user_code = ?', [req.params.user_code]);
134
+ if (!p)
135
+ return void res.status(404).json({ error: 'pairing_not_found' });
136
+ if (!pairingApprovable(p, now))
137
+ return void res.status(409).json({ error: 'pairing_not_pending_or_expired', status: p.status });
138
+ // Re-validate scopes at approval time (defense in depth) — must still be safe-only.
139
+ const caps = safeParseCaps(p.capabilities);
140
+ const v = validateRequestedCapabilities(caps);
141
+ if (!v.ok)
142
+ return void res.status(403).json({ error: 'pairing_rejected', rejected: v.rejected });
143
+ const grantId = generateId('grt');
144
+ const expiresAt = new Date(Date.now() + clampTtlSeconds(undefined) * 1000).toISOString();
145
+ // Grant created WITHOUT a token (token_hash NULL) — the bearer is minted only at retrieval.
146
+ await dbRun('INSERT INTO agent_delegation_grants (grant_id, human_id, agent_label, capabilities, token_hash, human_confirm_required, status, expires_at) VALUES (?,?,?,?,?,?,?,?)', [grantId, user.id, p.agent_label || null, JSON.stringify(caps), null, 0, 'active', expiresAt]);
147
+ await dbRun("UPDATE agent_pairing_sessions SET status='approved', human_id=?, grant_id=?, approved_at=? WHERE user_code=? AND status='pending'", [user.id, grantId, now, req.params.user_code]);
148
+ res.json({ success: true, grant_id: grantId, capabilities: caps });
149
+ });
150
+ // (pair 4) Agent retrieves the credential ONCE via PKCE verifier — UNAUTHENTICATED (PKCE-gated).
151
+ app.post('/api/agent-grants/pair/:pairing_id/retrieve', async (req, res) => {
152
+ const now = new Date().toISOString();
153
+ const verifier = typeof req.body?.code_verifier === 'string' ? req.body.code_verifier : '';
154
+ const p = await dbOne('SELECT * FROM agent_pairing_sessions WHERE pairing_id = ?', [req.params.pairing_id]);
155
+ if (!p)
156
+ return void res.status(404).json({ error: 'pairing_not_found' });
157
+ if (p.status === 'consumed' || p.consumed_at)
158
+ return void res.status(409).json({ error: 'pairing_already_consumed' });
159
+ if (!pairingRetrievable(p, now))
160
+ return void res.status(409).json({ error: 'pairing_not_approved_or_expired', status: p.status });
161
+ if (!verifyPkceS256(verifier, String(p.code_challenge)))
162
+ return void res.status(403).json({ error: 'pkce_mismatch' });
163
+ // Confirm the issued grant is still active (could have been revoked between approve and retrieve).
164
+ const grant = await dbOne('SELECT grant_id, status, capabilities, expires_at FROM agent_delegation_grants WHERE grant_id = ?', [String(p.grant_id)]);
165
+ if (!grant || grant.status !== 'active')
166
+ return void res.status(409).json({ error: 'grant_inactive' });
167
+ // Mint the bearer ONCE here; persist only its SHA-256 hash. Raw bearer is returned a single time.
168
+ const token = `gtk_${randomBytes(32).toString('hex')}`;
169
+ const tokenHash = createHash('sha256').update(token).digest('hex');
170
+ // One-time consume: only succeeds if still approved+unconsumed (guards against retrieval races/reuse).
171
+ const consumed = await dbRun("UPDATE agent_pairing_sessions SET status='consumed', consumed_at=? WHERE pairing_id=? AND status='approved' AND consumed_at IS NULL", [now, req.params.pairing_id]);
172
+ if (!consumed || consumed.changes !== 1)
173
+ return void res.status(409).json({ error: 'pairing_already_consumed' });
174
+ await dbRun('UPDATE agent_delegation_grants SET token_hash=? WHERE grant_id=?', [tokenHash, grant.grant_id]);
175
+ res.json({
176
+ grant_id: grant.grant_id,
177
+ token, // shown ONCE — agent stores it locally; server keeps only the hash
178
+ token_note: 'Shown once. Store in your OS secret store; the server keeps only a hash and cannot reissue it.',
179
+ capabilities: safeParseCaps(grant.capabilities),
180
+ expires_at: grant.expires_at,
181
+ });
182
+ });
183
+ // ── Issue a grant (human-authenticated). Safe scopes only; risk/never-delegable rejected. ──
184
+ app.post('/api/agent-grants', async (req, res) => {
185
+ const user = auth(req, res);
186
+ if (!user)
187
+ return;
188
+ const body = (req.body || {});
189
+ const caps = Array.isArray(body.capabilities) ? body.capabilities : [];
190
+ const v = validateRequestedCapabilities(caps);
191
+ if (!v.ok) {
192
+ // Fail-closed: any risk / never-delegable / unknown scope rejects the whole request.
193
+ return void res.status(403).json({ error: 'grant_rejected', rejected: v.rejected });
194
+ }
195
+ const ttl = clampTtlSeconds(body.ttl_seconds);
196
+ const grantId = generateId('grt');
197
+ const token = `gtk_${randomBytes(32).toString('hex')}`; // bearer — shown once
198
+ const tokenHash = createHash('sha256').update(token).digest('hex');
199
+ const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
200
+ const label = typeof body.agent_label === 'string' ? body.agent_label.slice(0, 120) : null;
201
+ const capsJson = JSON.stringify(v.safe.map(c => ({
202
+ capability: c,
203
+ constraints: (caps.find(x => x?.capability === c)?.constraints) || {},
204
+ })));
205
+ await dbRun('INSERT INTO agent_delegation_grants (grant_id, human_id, agent_label, capabilities, token_hash, human_confirm_required, status, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [grantId, user.id, label, capsJson, tokenHash, 0, 'active', expiresAt]);
206
+ res.status(201).json({
207
+ grant_id: grantId,
208
+ token,
209
+ token_note: 'Shown once — store securely. The server keeps only a hash; it cannot show this again.',
210
+ capabilities: JSON.parse(capsJson),
211
+ expires_at: expiresAt,
212
+ note: 'Bearer-first grant for safe scopes only. Risk scopes are not delegable until their route has a live-Passkey gate; PoP binding is required before any risk scope or longer-lived delegation (RFC-020).',
213
+ });
214
+ });
215
+ // ── Read: the human's connected agents (no secrets) + recent-use from the audit log (PR-D). ──
216
+ // last_used_at / use_count come from agent_grant_auth_log (RFC-020 §3.7) — the data the
217
+ // "Connected agents" UI shows so a human can spot stale/unused or busy agents before revoking.
218
+ app.get('/api/agent-grants', async (req, res) => {
219
+ const user = auth(req, res);
220
+ if (!user)
221
+ return;
222
+ const rows = await dbAll(`SELECT g.grant_id, g.agent_label, g.capabilities, g.status, g.created_at, g.expires_at, g.revoked_at, g.revoked_reason,
223
+ MAX(CASE WHEN l.outcome = 'allow' THEN l.ts END) AS last_used_at,
224
+ COUNT(CASE WHEN l.outcome = 'allow' THEN 1 END) AS use_count
225
+ FROM agent_delegation_grants g
226
+ LEFT JOIN agent_grant_auth_log l ON l.grant_id = g.grant_id
227
+ WHERE g.human_id = ?
228
+ GROUP BY g.grant_id
229
+ ORDER BY g.created_at DESC`, [user.id]);
230
+ const now = new Date().toISOString();
231
+ res.json({
232
+ grants: rows.map(g => ({
233
+ ...g,
234
+ capabilities: safeParseCaps(g.capabilities),
235
+ use_count: Number(g.use_count) || 0,
236
+ active: grantIsActive(g, now),
237
+ })),
238
+ });
239
+ });
240
+ // ── Revoke (online, one-click). ──
241
+ app.post('/api/agent-grants/:grant_id/revoke', async (req, res) => {
242
+ const user = auth(req, res);
243
+ if (!user)
244
+ return;
245
+ const grantId = req.params.grant_id;
246
+ const g = await dbOne('SELECT grant_id, status FROM agent_delegation_grants WHERE grant_id = ? AND human_id = ?', [grantId, user.id]);
247
+ if (!g)
248
+ return void res.status(404).json({ error: 'grant_not_found' });
249
+ if (g.status === 'revoked')
250
+ return void res.json({ success: true, already_revoked: true, grant_id: grantId });
251
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 200) : null;
252
+ await dbRun("UPDATE agent_delegation_grants SET status = 'revoked', revoked_at = ?, revoked_reason = ? WHERE grant_id = ? AND human_id = ?", [new Date().toISOString(), reason, grantId, user.id]);
253
+ res.json({ success: true, grant_id: grantId });
254
+ });
255
+ }
@@ -1,7 +1,7 @@
1
1
  import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
2
  export function registerAuthReadRoutes(app, deps) {
3
3
  // db 已全量走 RFC-016 异步 seam(dbOne),不再直接用 deps.db
4
- const { auth, safeRoles, getRegionMaxLevels, userMlmGate, getUserLevel } = deps;
4
+ const { auth, safeRoles, getRegionMaxLevels, userMlmGate } = deps;
5
5
  app.get('/api/me', async (req, res) => {
6
6
  const user = auth(req, res);
7
7
  if (!user)
@@ -34,7 +34,6 @@ export function registerAuthReadRoutes(app, deps) {
34
34
  const wallet = await dbOne('SELECT balance, staked, escrowed, earned FROM wallets WHERE user_id = ?', [user.id]);
35
35
  const roles = safeRoles(user);
36
36
  const pv = await dbOne("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?", [user.id]);
37
- const pendingScore = (await dbOne("SELECT COALESCE(SUM(score),0) as s FROM binary_score_records WHERE user_id = ? AND settled_at IS NULL", [user.id])).s;
38
37
  res.json({
39
38
  id: user.id, name: user.name, role: user.role, roles, api_key: user.api_key, wallet: wallet || null,
40
39
  permanent_code: user.permanent_code ?? null,
@@ -69,11 +68,8 @@ export function registerAuthReadRoutes(app, deps) {
69
68
  return null;
70
69
  }
71
70
  })(),
72
- pending_score: Number(pendingScore),
73
71
  total_left_pv: Number(pv?.total_left_pv ?? 0),
74
72
  total_right_pv: Number(pv?.total_right_pv ?? 0),
75
- lifetime_score: Number(user.lifetime_score ?? 0),
76
- user_level: getUserLevel(Number(user.lifetime_score ?? 0)),
77
73
  });
78
74
  });
79
75
  }
@@ -1,8 +1,8 @@
1
1
  import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
2
  export function registerAuthRegisterRoutes(app, deps) {
3
- // VALID_REGIONS + INVITE_ROTATION_HANDLES 通过 deps.X 在 handler 内延迟读
4
- // (server.ts 用 getter 注入;destructure at register-time would trigger TDZ 因为它们在下方 const)
5
- const { db, errorRes, INTERNAL_AUDITOR_ID, isAllowedSponsor, resolveInviteCodeRef, generateId, generateSecureKey, generatePermanentCode, deriveHandle, clientIpHash, clientUaHash, pickPreferredSide, joinPowerLeg, inviteRotationLookup, issueCode, findActiveCode, canDeliverCodes, emailDeliveryNotConfigured, recordSession, broadcastSystemEvent } = deps;
3
+ // VALID_REGIONS 通过 deps.X 在 handler 内延迟读
4
+ // (server.ts 用 getter 注入;destructure at register-time would trigger TDZ 因为它在下方 const)
5
+ const { db, errorRes, INTERNAL_AUDITOR_ID, isAllowedSponsor, resolveInviteCodeRef, generateId, generateSecureKey, generatePermanentCode, deriveHandle, clientIpHash, clientUaHash, pickPreferredSide, joinPowerLeg, issueCode, findActiveCode, canDeliverCodes, emailDeliveryNotConfigured, recordSession, broadcastSystemEvent } = deps;
6
6
  // CODE_TTL_MIN / MAX_CODE_ATTEMPTS 通过 deps.X 在 handler 内延迟读(它们在 server.ts 是后置 const,
7
7
  // register-time destructure 会触发 TDZ)。
8
8
  const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
@@ -205,16 +205,6 @@ export function registerAuthRegisterRoutes(app, deps) {
205
205
  }
206
206
  }
207
207
  }
208
- const rotationEnabled = db.prepare("SELECT value FROM system_state WHERE key='invite_rotation_enabled'").get()?.value === '1';
209
- if (rotationEnabled && sponsorId) {
210
- for (let i = 0; i < deps.INVITE_ROTATION_HANDLES.length; i++) {
211
- const u = inviteRotationLookup(i);
212
- if (u && u.id === sponsorId) {
213
- db.prepare("UPDATE invite_rotation_stats SET registered_count = registered_count + 1 WHERE slot = ?").run(i);
214
- break;
215
- }
216
- }
217
- }
218
208
  return { placement, effectiveInviter, effectiveSide };
219
209
  });
220
210
  let txResult;
@@ -0,0 +1,113 @@
1
+ import { createQuotaRequest, listMyQuotaRequests, listQuotaRequests, getQuotaRequest, approveQuotaRequest, rejectQuotaRequest, revokeQuotaRequest, requesterUsage24h, remainingQuota, isQuotaError, } from '../../layer2-business/L2-9-contribution/build-task-quota.js';
2
+ // map a store error_code to an HTTP status
3
+ function httpFor(code) {
4
+ if (code === 'NOT_FOUND')
5
+ return 404;
6
+ if (code === 'ALREADY_PENDING' || code === 'BAD_STATE')
7
+ return 409;
8
+ if (code === 'SELF_DECISION')
9
+ return 403;
10
+ return 400;
11
+ }
12
+ // parse the stored linked_refs JSON + surface a derived remaining count for approved grants
13
+ function shapeRequest(r) {
14
+ let linked = [];
15
+ try {
16
+ linked = JSON.parse(String(r.linked_refs ?? '[]'));
17
+ }
18
+ catch {
19
+ linked = [];
20
+ }
21
+ const granted = r.granted_count == null ? null : Number(r.granted_count);
22
+ const consumed = Number(r.consumed_count ?? 0);
23
+ return { ...r, linked_refs: linked, remaining: granted == null ? null : Math.max(0, granted - consumed) };
24
+ }
25
+ export function registerBuildTaskQuotaRoutes(app, deps) {
26
+ const { db, errorRes, auth, requireRootAdmin } = deps;
27
+ // ── requester surface ─────────────────────────────────────────────────────
28
+ // submit a quota-increase request
29
+ app.post('/api/me/quota-requests', (req, res) => {
30
+ const user = auth(req, res);
31
+ if (!user)
32
+ return;
33
+ const b = (req.body ?? {});
34
+ const r = createQuotaRequest(db, {
35
+ requesterId: String(user.id),
36
+ requestedExtraCount: Number(b.requested_extra_count),
37
+ reason: String(b.reason ?? ''),
38
+ linkedRefs: b.linked_refs,
39
+ urgency: b.urgency,
40
+ requestedDurationHours: b.requested_duration_hours == null ? null : Number(b.requested_duration_hours),
41
+ quotaType: b.quota_type,
42
+ });
43
+ if (isQuotaError(r))
44
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
45
+ res.json({ request: r });
46
+ });
47
+ // list my own requests + current remaining temporary quota
48
+ app.get('/api/me/quota-requests', (req, res) => {
49
+ const user = auth(req, res);
50
+ if (!user)
51
+ return;
52
+ const requests = listMyQuotaRequests(db, String(user.id)).map(shapeRequest);
53
+ res.json({ requests, remaining_quota: remainingQuota(db, String(user.id)) });
54
+ });
55
+ // ── ROOT admin review surface ─────────────────────────────────────────────
56
+ // list quota requests (optional ?status=)
57
+ app.get('/api/admin/quota-requests', (req, res) => {
58
+ const admin = requireRootAdmin(req, res);
59
+ if (!admin)
60
+ return;
61
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
62
+ const requests = listQuotaRequests(db, { status }).map(shapeRequest);
63
+ res.json({ requests });
64
+ });
65
+ // detail of one request + the requester's live 24h create usage (reviewer context)
66
+ app.get('/api/admin/quota-requests/:id', (req, res) => {
67
+ const admin = requireRootAdmin(req, res);
68
+ if (!admin)
69
+ return;
70
+ const r = getQuotaRequest(db, String(req.params.id));
71
+ if (!r)
72
+ return void errorRes(res, 404, 'NOT_FOUND', 'quota request not found');
73
+ res.json({ request: shapeRequest(r), requester_usage_24h: requesterUsage24h(db, String(r.requester_user_id)) });
74
+ });
75
+ // approve → time-boxed counted grant (self-approval rejected in the store)
76
+ app.post('/api/admin/quota-requests/:id/approve', (req, res) => {
77
+ const admin = requireRootAdmin(req, res);
78
+ if (!admin)
79
+ return;
80
+ const b = (req.body ?? {});
81
+ const r = approveQuotaRequest(db, String(req.params.id), String(admin.id), {
82
+ grantedCount: b.extra_count == null ? undefined : Number(b.extra_count),
83
+ durationHours: b.duration_hours == null ? undefined : Number(b.duration_hours),
84
+ expiresAt: typeof b.expires_at === 'string' ? b.expires_at : undefined,
85
+ decisionNote: typeof b.approval_note === 'string' ? b.approval_note : undefined,
86
+ });
87
+ if (isQuotaError(r))
88
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
89
+ res.json({ approved: r });
90
+ });
91
+ // reject (self-rejection also blocked by the store's SELF_DECISION guard)
92
+ app.post('/api/admin/quota-requests/:id/reject', (req, res) => {
93
+ const admin = requireRootAdmin(req, res);
94
+ if (!admin)
95
+ return;
96
+ const b = (req.body ?? {});
97
+ const r = rejectQuotaRequest(db, String(req.params.id), String(admin.id), { decisionNote: typeof b.rejection_note === 'string' ? b.rejection_note : undefined });
98
+ if (isQuotaError(r))
99
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
100
+ res.json({ rejected: r });
101
+ });
102
+ // revoke an already-approved grant (root)
103
+ app.post('/api/admin/quota-requests/:id/revoke', (req, res) => {
104
+ const admin = requireRootAdmin(req, res);
105
+ if (!admin)
106
+ return;
107
+ const b = (req.body ?? {});
108
+ const r = revokeQuotaRequest(db, String(req.params.id), String(admin.id), { decisionNote: typeof b.revocation_note === 'string' ? b.revocation_note : undefined });
109
+ if (isQuotaError(r))
110
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
111
+ res.json({ revoked: r });
112
+ });
113
+ }
@@ -1,4 +1,6 @@
1
1
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
2
+ // #420 P1-3 — verifier outlier 阈值改由 governance-adjustable protocol_params 驱动
3
+ import { readAntiAbuseThresholds, verifierOutlierBand } from '../anti-abuse-thresholds.js';
2
4
  // RFC-016 Phase 1 — 仅端点纯校验读/列表/公开查询/读回 + 单语句标记/字段写 + 写后通知 → async seam。
3
5
  // 全部保持同步(Phase 3 再用 pg tx/行锁):
4
6
  // - 模块级 helper(settleClaimTask 三路径结算 / distributePool / checkAndApplyOutlierStrike /
@@ -23,11 +25,10 @@ const CLAIM_VALID_VOTES = new Set(['pass', 'fail', 'no_fault', 'abstain']);
23
25
  // V3:abstain 不计入 3-vote 共识、不参与 majority、不触发 outlier
24
26
  const CLAIM_SELLER_FINE_RATE = 0.10; // pass 时扣 product.stake_amount × 10%
25
27
  const CLAIM_NO_FAULT_SUBSIDY = 1; // no_fault 路径协议池补贴每个 verifier 1 WAZ
26
- // 跨域共用(server.ts checkVerifierOutlier 6 套 vote table 聚合也用同一阈值)
27
- export const CLAIM_SUSPEND_THRESHOLD = 3; // 180d 内 ≥3 次 outlier → 30d 冻结
28
- export const CLAIM_REVOKE_THRESHOLD = 5; // 180d ≥5 次 outlier → 永封
29
- export const CLAIM_SUSPEND_DAYS = 30;
30
- export const CLAIM_OUTLIER_WINDOW_DAYS = 180;
28
+ // #420 P1-3:verifier outlier 阈值(暂停/撤销/窗口/暂停时长)已抽到 governance-adjustable
29
+ // protocol_params,单一真相源在 ../anti-abuse-thresholds.ts(DEFAULT_ANTI_ABUSE_THRESHOLDS:
30
+ // outlierSuspendCount=3 / outlierRevokeCount=5 / outlierSuspendDays=30 / outlierWindowDays=180)。
31
+ // checkAndApplyOutlierStrike + server.ts checkVerifierOutlier 通过 readAntiAbuseThresholds(db) 读取。
31
32
  // ─── helpers (module-level, db 通过参数传) ───────────────────
32
33
  // 2026-05-22 V2:通知所有资格内 verifier 有新 claim 任务
33
34
  export function notifyEligibleVerifiers(db, generateId, args) {
@@ -99,28 +100,31 @@ export function activeClaimTaskCountForVerifier(db, userId) {
99
100
  }
100
101
  // M7.3b:单个 outlier 处罚检查
101
102
  function checkAndApplyOutlierStrike(db, generateId, userId) {
103
+ // #420 P1-3:窗口/暂停/撤销阈值由 protocol_params 驱动(默认 = 原 180d/≥5/≥3/30d)
104
+ const t = readAntiAbuseThresholds(db);
102
105
  const cnt = db.prepare(`
103
106
  SELECT COUNT(*) as n FROM claim_verification_votes cvv
104
107
  JOIN claim_verification_tasks cvt ON cvt.id = cvv.task_id
105
108
  WHERE cvv.verifier_id = ?
106
109
  AND cvv.was_majority = 0
107
110
  AND cvt.resolved_at IS NOT NULL
108
- AND cvt.resolved_at >= datetime('now', '-${CLAIM_OUTLIER_WINDOW_DAYS} days')
111
+ AND cvt.resolved_at >= datetime('now', '-${t.outlierWindowDays} days')
109
112
  `).get(userId).n;
110
113
  const existing = db.prepare(`SELECT type, outlier_count FROM claim_verifier_suspensions
111
114
  WHERE user_id = ? AND (type = 'revoked' OR until_at > datetime('now'))
112
115
  ORDER BY created_at DESC LIMIT 1`).get(userId);
113
116
  if (existing?.type === 'revoked')
114
117
  return { strikes_180d: cnt };
115
- if (cnt >= CLAIM_REVOKE_THRESHOLD && (!existing || existing.outlier_count < CLAIM_REVOKE_THRESHOLD)) {
118
+ const band = verifierOutlierBand(cnt, t);
119
+ if (band === 'revoke' && (!existing || existing.outlier_count < t.outlierRevokeCount)) {
116
120
  db.prepare(`INSERT INTO claim_verifier_suspensions (id, user_id, type, reason, outlier_count)
117
- VALUES (?,?, 'revoked', ?, ?)`).run(generateId('cvs'), userId, `180d 内累计 ${cnt} 次 outlier`, cnt);
121
+ VALUES (?,?, 'revoked', ?, ?)`).run(generateId('cvs'), userId, `${t.outlierWindowDays}d 内累计 ${cnt} 次 outlier`, cnt);
118
122
  return { strikes_180d: cnt, suspension: { type: 'revoked', until_at: null } };
119
123
  }
120
- if (cnt >= CLAIM_SUSPEND_THRESHOLD && !existing) {
121
- const until = new Date(Date.now() + CLAIM_SUSPEND_DAYS * 86400_000).toISOString();
124
+ if (band === 'suspend' && !existing) {
125
+ const until = new Date(Date.now() + t.outlierSuspendDays * 86400_000).toISOString();
122
126
  db.prepare(`INSERT INTO claim_verifier_suspensions (id, user_id, type, until_at, reason, outlier_count)
123
- VALUES (?,?, 'suspended', ?, ?, ?)`).run(generateId('cvs'), userId, until, `180d 内累计 ${cnt} 次 outlier`, cnt);
127
+ VALUES (?,?, 'suspended', ?, ?, ?)`).run(generateId('cvs'), userId, until, `${t.outlierWindowDays}d 内累计 ${cnt} 次 outlier`, cnt);
124
128
  return { strikes_180d: cnt, suspension: { type: 'suspended', until_at: until } };
125
129
  }
126
130
  return { strikes_180d: cnt };
@@ -0,0 +1,18 @@
1
+ import { getMyContributionFacts } from '../../layer2-business/L2-9-contribution/contribution-facts-read.js';
2
+ import { withUncommittedValueBoundary } from '../../layer2-business/L2-9-contribution/contribution-display-envelope.js';
3
+ export function registerContributionFactsRoutes(app, deps) {
4
+ const { db, auth, errorRes } = deps;
5
+ // ── READ-ONLY: the caller's OWN attributable contribution facts (GitHub + admin coordination) ──
6
+ app.get('/api/contribution-facts/me', (req, res) => {
7
+ const user = auth(req, res);
8
+ if (!user)
9
+ return;
10
+ try {
11
+ const surface = getMyContributionFacts(db, user.id);
12
+ res.json(withUncommittedValueBoundary(surface));
13
+ }
14
+ catch {
15
+ return void errorRes(res, 500, 'INTERNAL', '内部错误'); // never leak a stack / query
16
+ }
17
+ });
18
+ }
@@ -1,4 +1,5 @@
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 registerDisputeCasesRoutes(app, deps) {
3
4
  const { db, auth, getUser, generateId, piiSanitize, detectFraud, commentBlocklistHit, llmModerateComment } = deps;
4
5
  // 公共发言门槛 — 防新号/小号刷评论/投票
@@ -7,7 +8,7 @@ export function registerDisputeCasesRoutes(app, deps) {
7
8
  const lifetime = Number(user.lifetime_score || 0);
8
9
  if (lifetime >= 5)
9
10
  return { ok: true };
10
- const completed = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE (buyer_id = ? OR seller_id = ?) AND status = 'completed'`, [user.id, user.id])).n;
11
+ const completed = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE (buyer_id = ? OR seller_id = ?) AND ${genuineSalePredicate('orders')}`, [user.id, user.id])).n; // 真实成交,排除退款/违约
11
12
  if (completed >= 1)
12
13
  return { ok: true };
13
14
  const created = user.created_at ? new Date(String(user.created_at).replace(' ', 'T') + 'Z').getTime() : 0;
@@ -93,9 +94,9 @@ export function registerDisputeCasesRoutes(app, deps) {
93
94
  };
94
95
  // 评论 + 自动身份标签
95
96
  const rawComments = await dbAll(`
96
- SELECT dc.*, u.handle, u.name, u.role, u.lifetime_score,
97
+ SELECT dc.*, u.handle, u.name, u.role,
97
98
  (SELECT COUNT(*) FROM orders o
98
- WHERE o.buyer_id = dc.commenter_id AND o.product_id = ? AND o.status = 'completed') as bought_count,
99
+ WHERE o.buyer_id = dc.commenter_id AND o.product_id = ? AND ${genuineSalePredicate('o')}) as bought_count,
99
100
  (SELECT COUNT(*) FROM products p
100
101
  WHERE p.seller_id = dc.commenter_id AND p.category = (SELECT category FROM products WHERE id = ?) AND p.status = 'active') as same_cat_seller_count
101
102
  FROM dispute_comments dc
@@ -118,7 +119,7 @@ export function registerDisputeCasesRoutes(app, deps) {
118
119
  // W5: 取所有子回复,按 parent_comment_id 分组挂在 comments 下
119
120
  const commentIds = rawComments.map(r => r.id);
120
121
  const rawReplies = commentIds.length > 0 ? await dbAll(`
121
- SELECT r.*, u.handle, u.name, u.role, u.lifetime_score
122
+ SELECT r.*, u.handle, u.name, u.role
122
123
  FROM dispute_comment_replies r LEFT JOIN users u ON u.id = r.replier_id
123
124
  WHERE r.parent_comment_id IN (${commentIds.map(() => '?').join(',')})
124
125
  ORDER BY r.created_at ASC
@@ -49,7 +49,7 @@ const GROWTH_TASK_CATALOG = [
49
49
  { id: 'tier1_match', chapter: 3,
50
50
  title_zh: '持续贡献阶段 1', title_en: 'Contribution stage 1',
51
51
  desc_zh: '推荐网络贡献达到第一阶段标准', desc_en: 'Referral-network contribution reaches stage-1 threshold',
52
- evaluate: c => c.weak_leg_pv >= 30000 },
52
+ evaluate: c => c.min_leg_pv >= 30000 },
53
53
  // 第 4 关:分享达人
54
54
  { id: 'monthly_100', chapter: 4,
55
55
  title_zh: '月度推荐收益 100 WAZ', title_en: 'Monthly referral income 100 WAZ',
@@ -83,7 +83,7 @@ async function buildGrowthTaskCtx(_db, userId) {
83
83
  const sCount = (await dbOne("SELECT COUNT(*) AS n FROM shareables WHERE owner_id = ? AND status = 'active'", [userId])).n;
84
84
  const mCount = (await dbOne("SELECT COUNT(*) AS n FROM manifest_registry WHERE owner_id = ? AND status = 'active'", [userId])).n;
85
85
  const pv = await dbOne("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?", [userId]);
86
- const weakLeg = Math.min(Number(pv?.total_left_pv || 0), Number(pv?.total_right_pv || 0));
86
+ const minLeg = Math.min(Number(pv?.total_left_pv || 0), Number(pv?.total_right_pv || 0));
87
87
  const comm30 = (await dbOne(`SELECT COALESCE(SUM(amount),0) AS s FROM commission_records WHERE beneficiary_id = ? AND created_at >= datetime('now','-30 days')`, [userId])).s;
88
88
  const waz30 = (await dbOne(`SELECT COALESCE(SUM(waz_amount),0) AS s FROM binary_score_records WHERE user_id = ? AND settled_at >= datetime('now','-30 days')`, [userId])).s;
89
89
  return {
@@ -97,7 +97,7 @@ async function buildGrowthTaskCtx(_db, userId) {
97
97
  earnings_grand: grand,
98
98
  shareables_count: sCount,
99
99
  manifests_count: mCount,
100
- weak_leg_pv: weakLeg,
100
+ min_leg_pv: minLeg,
101
101
  last_30_total: comm30 + waz30,
102
102
  };
103
103
  }