@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.
- package/LICENSE +2 -2
- package/NOTICE +24 -3
- package/README.md +74 -330
- package/README.zh-CN.md +419 -0
- package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +69 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +270 -82
- package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
- package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
- package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
- package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +24 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
- package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +57 -7
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
- package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +187 -18
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
- package/dist/ledger.js +1 -1
- package/dist/pwa/admin-audit.js +38 -0
- package/dist/pwa/anti-abuse-thresholds.js +135 -0
- package/dist/pwa/cf-origin-guard.js +33 -0
- package/dist/pwa/contract-fingerprint.js +1 -0
- package/dist/pwa/data/onboarding-cases.js +2 -2
- package/dist/pwa/data/onboarding-quiz.js +1 -1
- package/dist/pwa/economic-participation.js +2 -2
- package/dist/pwa/integration-contract.js +46 -4
- package/dist/pwa/internal/pv-settlement.js +12 -0
- package/dist/pwa/internal/wallet-signer.js +26 -0
- package/dist/pwa/public/app-account.js +977 -0
- package/dist/pwa/public/app-admin.js +608 -0
- package/dist/pwa/public/app-agents.js +63 -0
- package/dist/pwa/public/app-ai.js +2162 -0
- package/dist/pwa/public/app-contribution.js +836 -0
- package/dist/pwa/public/app-discover.js +1296 -0
- package/dist/pwa/public/app-listings.js +226 -0
- package/dist/pwa/public/app-profile.js +1692 -0
- package/dist/pwa/public/app-seller.js +199 -0
- package/dist/pwa/public/app-shop.js +1145 -0
- package/dist/pwa/public/app.js +15075 -23960
- package/dist/pwa/public/i18n.js +31 -28
- package/dist/pwa/public/index.html +11 -1
- package/dist/pwa/public/openapi.json +4851 -2776
- package/dist/pwa/pv-kill-switch.js +31 -0
- package/dist/pwa/routes/admin-admins.js +48 -1
- package/dist/pwa/routes/admin-analytics.js +1 -10
- package/dist/pwa/routes/admin-atomic.js +4 -17
- package/dist/pwa/routes/admin-operator-claims.js +280 -0
- package/dist/pwa/routes/admin-reports.js +4 -26
- package/dist/pwa/routes/admin-tokenomics.js +2 -76
- package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
- package/dist/pwa/routes/admin-users-query.js +23 -1
- package/dist/pwa/routes/admin-wallet-ops.js +1 -1
- package/dist/pwa/routes/agent-grants.js +255 -0
- package/dist/pwa/routes/auth-read.js +1 -5
- package/dist/pwa/routes/auth-register.js +3 -13
- package/dist/pwa/routes/build-task-quota.js +113 -0
- package/dist/pwa/routes/claim-verify.js +15 -11
- package/dist/pwa/routes/contribution-facts.js +18 -0
- package/dist/pwa/routes/dispute-cases.js +5 -4
- package/dist/pwa/routes/growth.js +3 -3
- package/dist/pwa/routes/orders-action.js +27 -10
- package/dist/pwa/routes/orders-create.js +1 -1
- package/dist/pwa/routes/products-meta.js +19 -6
- package/dist/pwa/routes/profile-placement.js +1 -1
- package/dist/pwa/routes/promoter.js +10 -29
- package/dist/pwa/routes/public-build-tasks.js +5 -1
- package/dist/pwa/routes/public-utils.js +9 -12
- package/dist/pwa/routes/referral.js +5 -26
- package/dist/pwa/routes/rewards-apply.js +3 -2
- package/dist/pwa/routes/share-redirects.js +1 -1
- package/dist/pwa/routes/shareables-interactions.js +2 -1
- package/dist/pwa/routes/task-proposals.js +85 -9
- package/dist/pwa/routes/users-public.js +1 -4
- package/dist/pwa/routes/wallet-read.js +2 -14
- package/dist/pwa/routes/webauthn.js +7 -2
- package/dist/pwa/server-schema.js +9 -0
- package/dist/pwa/server.js +319 -2034
- package/dist/runtime/agent-grant-scopes.js +128 -0
- package/dist/runtime/agent-grant-verifier.js +67 -0
- package/dist/runtime/agent-pairing.js +60 -0
- package/dist/runtime/apply-webaz-runtime-schema.js +15 -0
- package/dist/runtime/webaz-schema-helpers.js +1848 -0
- package/dist/settlement-math.js +3 -3
- package/dist/version.js +6 -4
- package/package.json +43 -8
- package/dist/index.js +0 -182
- package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
- package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
- package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
- package/dist/test-dispute.js +0 -153
- package/dist/test-manifest.js +0 -61
- package/dist/test-mcp-tools.js +0 -135
- package/dist/test-reputation.js +0 -116
- 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
|
-
//
|
|
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
|
|
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
|
|
4
|
-
// (server.ts 用 getter 注入;destructure at register-time would trigger TDZ
|
|
5
|
-
const { db, errorRes, INTERNAL_AUDITOR_ID, isAllowedSponsor, resolveInviteCodeRef, generateId, generateSecureKey, generatePermanentCode, deriveHandle, clientIpHash, clientUaHash, pickPreferredSide, joinPowerLeg,
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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', '-${
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
121
|
-
const until = new Date(Date.now() +
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
100
|
+
min_leg_pv: minLeg,
|
|
101
101
|
last_30_total: comm30 + waz30,
|
|
102
102
|
};
|
|
103
103
|
}
|