@seasonkoh/webaz 0.1.26 → 0.1.27
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/server.js +36 -28
- 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.js +679 -679
- package/dist/pwa/public/i18n.js +15 -28
- package/dist/pwa/public/index.html +1 -1
- package/dist/pwa/public/openapi.json +4760 -2769
- 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/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 +1 -1
- package/dist/pwa/server.js +156 -469
- package/dist/settlement-math.js +3 -3
- package/dist/version.js +6 -4
- package/package.json +33 -7
- 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
|
@@ -1,16 +1,51 @@
|
|
|
1
|
-
import { createBuildTask, logTaskEvent } from './build-tasks-engine.js';
|
|
2
|
-
import { insertBuildTaskAgentMetadata, getBuildTaskAgentMetadata, setBuildTaskAudience, RISK_LEVELS, TASK_TYPES } from './build-task-agent-metadata-store.js';
|
|
1
|
+
import { createBuildTask, logTaskEvent, releaseExpiredClaims } from './build-tasks-engine.js';
|
|
2
|
+
import { insertBuildTaskAgentMetadata, getBuildTaskAgentMetadata, setBuildTaskAudience, setBuildTaskEstimate, RISK_LEVELS, TASK_TYPES, CONTEXT_SIZES, AGENT_BUDGETS } from './build-task-agent-metadata-store.js';
|
|
3
3
|
import { reviewTaskProposal } from './task-proposal-store.js';
|
|
4
4
|
/** Source-proposal ↔ draft-task link (lets a draft preserve its origin WITHOUT marking the proposal terminal). */
|
|
5
5
|
export function initTaskProposalDraftLinkSchema(db) {
|
|
6
6
|
db.exec(`CREATE TABLE IF NOT EXISTS task_proposal_draft_links (
|
|
7
|
-
task_id
|
|
8
|
-
proposal_id
|
|
9
|
-
created_by
|
|
10
|
-
|
|
7
|
+
task_id TEXT PRIMARY KEY,
|
|
8
|
+
proposal_id TEXT NOT NULL,
|
|
9
|
+
created_by TEXT NOT NULL,
|
|
10
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
11
|
+
discarded_by TEXT,
|
|
12
|
+
discarded_at TEXT,
|
|
13
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
11
14
|
)`);
|
|
12
|
-
//
|
|
13
|
-
|
|
15
|
+
// Additive migration for pre-existing DBs (ALTER after CREATE; fresh DBs already have these columns).
|
|
16
|
+
// status = 'active' | 'discarded'. A discarded link is SOFT-deleted: the row is retained for provenance
|
|
17
|
+
// (retroactive reward / anchor-side traceability / dispute), and it frees the proposal's draft slot.
|
|
18
|
+
try {
|
|
19
|
+
const cols = db.prepare('PRAGMA table_info(task_proposal_draft_links)').all();
|
|
20
|
+
if (!cols.some(c => c.name === 'status'))
|
|
21
|
+
db.exec("ALTER TABLE task_proposal_draft_links ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
|
|
22
|
+
if (!cols.some(c => c.name === 'discarded_by'))
|
|
23
|
+
db.exec('ALTER TABLE task_proposal_draft_links ADD COLUMN discarded_by TEXT');
|
|
24
|
+
if (!cols.some(c => c.name === 'discarded_at'))
|
|
25
|
+
db.exec('ALTER TABLE task_proposal_draft_links ADD COLUMN discarded_at TEXT');
|
|
26
|
+
}
|
|
27
|
+
catch { /* best-effort additive migration */ }
|
|
28
|
+
// PARTIAL UNIQUE: at most one ACTIVE draft per proposal (discarded links are retained but excluded), so a
|
|
29
|
+
// discarded draft truly frees the slot. Replaces the old full unique index on proposal_id.
|
|
30
|
+
try {
|
|
31
|
+
db.exec('DROP INDEX IF EXISTS idx_tpdl_proposal');
|
|
32
|
+
}
|
|
33
|
+
catch { /* noop */ }
|
|
34
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tpdl_proposal_active ON task_proposal_draft_links(proposal_id) WHERE status = 'active'");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* case_id threads a case end to end: proposal → task → PR. For a task converted from a proposal it is that
|
|
38
|
+
* source proposal id (so proposer + contributor + PR all quote the same id); for a directly-created task it
|
|
39
|
+
* is the task id itself. Guarded: the link table may be absent in minimal setups → fall back to the task id.
|
|
40
|
+
*/
|
|
41
|
+
export function caseIdForTask(db, taskId) {
|
|
42
|
+
try {
|
|
43
|
+
const link = db.prepare('SELECT proposal_id FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
|
|
44
|
+
if (link?.proposal_id)
|
|
45
|
+
return link.proposal_id;
|
|
46
|
+
}
|
|
47
|
+
catch { /* link table absent → case_id is the task id */ }
|
|
48
|
+
return taskId;
|
|
14
49
|
}
|
|
15
50
|
const strList = (v) => Array.isArray(v) ? v.slice(0, 50).map(s => String(s).slice(0, 500)).filter(s => s.trim()) : [];
|
|
16
51
|
const txt = (v) => (v ?? '').trim();
|
|
@@ -49,8 +84,8 @@ export function createDraftFromProposal(db, a) {
|
|
|
49
84
|
return { error: 'proposal not found', error_code: 'PROPOSAL_NOT_FOUND' };
|
|
50
85
|
if (prop.status === 'rejected' || prop.status === 'converted')
|
|
51
86
|
return { error: `proposal already ${prop.status}`, error_code: 'PROPOSAL_TERMINAL' };
|
|
52
|
-
// one draft per proposal (
|
|
53
|
-
const existingLink = db.prepare(
|
|
87
|
+
// one ACTIVE draft per proposal (a discarded draft is soft-deleted and frees the slot; not terminal-status based)
|
|
88
|
+
const existingLink = db.prepare("SELECT task_id FROM task_proposal_draft_links WHERE proposal_id = ? AND status != 'discarded'").get(a.proposalId);
|
|
54
89
|
if (existingLink)
|
|
55
90
|
return { error: `a task draft already exists for this proposal (${existingLink.task_id})`, error_code: 'PROPOSAL_HAS_DRAFT' };
|
|
56
91
|
// draft risk stays low/medium (high/critical metadata rules are out of scope for this PR)
|
|
@@ -60,6 +95,20 @@ export function createDraftFromProposal(db, a) {
|
|
|
60
95
|
if (a.riskLevel === 'high' || a.riskLevel === 'critical')
|
|
61
96
|
return { error: 'draft risk_level must be low or medium (high/critical deferred)', error_code: 'RISK_TOO_HIGH_FOR_DRAFT' };
|
|
62
97
|
const taskType = (a.taskType && TASK_TYPES.includes(a.taskType)) ? a.taskType : 'other';
|
|
98
|
+
// (a) #34/#5: an OPTIONAL real effort estimate. Provided → validated + stored; omitted → 0–0 placeholder
|
|
99
|
+
// (draft stays internal/unclaimable, and publishDraftBuildTask fails closed until a real estimate exists).
|
|
100
|
+
let durMin = 0, durMax = 0;
|
|
101
|
+
if (a.estimatedDurationMinMinutes !== undefined || a.estimatedDurationMaxMinutes !== undefined) {
|
|
102
|
+
const mn = Number(a.estimatedDurationMinMinutes), mx = Number(a.estimatedDurationMaxMinutes);
|
|
103
|
+
if (!Number.isInteger(mn) || !Number.isInteger(mx) || mn < 0 || mx < mn || mx < 1)
|
|
104
|
+
return { error: 'estimated_duration must be integer minutes with max >= min and max >= 1 (a real, non-zero estimate)', error_code: 'BAD_ESTIMATE' };
|
|
105
|
+
durMin = mn;
|
|
106
|
+
durMax = mx;
|
|
107
|
+
}
|
|
108
|
+
if (a.estimatedAgentBudget !== undefined && !AGENT_BUDGETS.includes(a.estimatedAgentBudget))
|
|
109
|
+
return { error: 'invalid estimated_agent_budget', error_code: 'BAD_AGENT_BUDGET' };
|
|
110
|
+
if (a.estimatedContextSize !== undefined && !CONTEXT_SIZES.includes(a.estimatedContextSize))
|
|
111
|
+
return { error: 'invalid estimated_context_size', error_code: 'BAD_CONTEXT_SIZE' };
|
|
63
112
|
// 2) assemble the agent-handoff fields and VALIDATE completeness BEFORE creating anything — the existing
|
|
64
113
|
// task model requires these to be executable, so we never create an incomplete/orphan task.
|
|
65
114
|
const allowed = strList(a.allowedPaths);
|
|
@@ -100,10 +149,10 @@ export function createDraftFromProposal(db, a) {
|
|
|
100
149
|
expected_results: expected,
|
|
101
150
|
deliverables: deliver,
|
|
102
151
|
definition_of_done: dod,
|
|
103
|
-
estimated_duration_min_minutes:
|
|
104
|
-
estimated_duration_max_minutes:
|
|
105
|
-
estimated_context_size: 'small',
|
|
106
|
-
estimated_agent_budget: 'minimal',
|
|
152
|
+
estimated_duration_min_minutes: durMin,
|
|
153
|
+
estimated_duration_max_minutes: durMax,
|
|
154
|
+
estimated_context_size: a.estimatedContextSize ?? 'small',
|
|
155
|
+
estimated_agent_budget: a.estimatedAgentBudget ?? 'minimal',
|
|
107
156
|
value_state: 'uncommitted',
|
|
108
157
|
contribution_type: 'task',
|
|
109
158
|
accountable_party_required: true,
|
|
@@ -123,7 +172,7 @@ export function listDraftBuildTasks(db) {
|
|
|
123
172
|
FROM build_tasks t
|
|
124
173
|
JOIN build_task_agent_metadata m ON m.task_id = t.id
|
|
125
174
|
LEFT JOIN task_proposal_draft_links l ON l.task_id = t.id
|
|
126
|
-
WHERE m.audience = 'internal' AND t.status = 'open'
|
|
175
|
+
WHERE m.audience = 'internal' AND t.status = 'open' AND (l.status IS NULL OR l.status != 'discarded')
|
|
127
176
|
ORDER BY t.created_at DESC LIMIT 200
|
|
128
177
|
`).all();
|
|
129
178
|
}
|
|
@@ -151,7 +200,7 @@ export function validateDraftForPublish(task, meta) {
|
|
|
151
200
|
* so a public, claimable task can never coexist with a rejected source proposal. The audience flip + proposal
|
|
152
201
|
* conversion run in one transaction (both or neither).
|
|
153
202
|
*/
|
|
154
|
-
export function publishDraftBuildTask(db, taskId, adminId) {
|
|
203
|
+
export function publishDraftBuildTask(db, taskId, adminId, estimate) {
|
|
155
204
|
const meta = getBuildTaskAgentMetadata(db, taskId);
|
|
156
205
|
if (!meta)
|
|
157
206
|
return { error: 'task has no draft metadata', error_code: 'NOT_A_DRAFT' };
|
|
@@ -164,9 +213,11 @@ export function publishDraftBuildTask(db, taskId, adminId) {
|
|
|
164
213
|
if (missing.length > 0)
|
|
165
214
|
return { error: `draft incomplete — fill before publish: ${missing.join(', ')}`, error_code: 'DRAFT_INCOMPLETE', missing };
|
|
166
215
|
// validate the linked proposal FIRST — do not publish on top of a rejected / elsewhere-converted proposal.
|
|
167
|
-
const link = db.prepare('SELECT proposal_id FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
|
|
216
|
+
const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
|
|
168
217
|
let proposalToConvert = null;
|
|
169
218
|
if (link) {
|
|
219
|
+
if (link.status === 'discarded')
|
|
220
|
+
return { error: 'draft was discarded — cannot publish', error_code: 'DRAFT_DISCARDED' };
|
|
170
221
|
const prop = db.prepare('SELECT status, converted_ref FROM task_proposals WHERE id = ?').get(link.proposal_id);
|
|
171
222
|
if (prop) {
|
|
172
223
|
if (prop.status === 'rejected')
|
|
@@ -177,8 +228,34 @@ export function publishDraftBuildTask(db, taskId, adminId) {
|
|
|
177
228
|
proposalToConvert = link.proposal_id;
|
|
178
229
|
}
|
|
179
230
|
}
|
|
180
|
-
//
|
|
231
|
+
// (a) #34/#5: FAIL-CLOSED on a placeholder estimate (checked AFTER proposal validity, so a dead/rejected
|
|
232
|
+
// source surfaces its own error first). A published task must carry a REAL effort estimate (non 0–0 /
|
|
233
|
+
// non-null duration), else the claim guard would correctly refuse it as manual_review forever ("published
|
|
234
|
+
// but unclaimable"). The maintainer may supply the estimate here; otherwise the draft's stored estimate must
|
|
235
|
+
// already be real. The override is validated + persisted inside the publish transaction below.
|
|
236
|
+
const overrideEstimate = !!estimate && (estimate.minMinutes !== undefined || estimate.maxMinutes !== undefined);
|
|
237
|
+
let effMin = meta.estimated_duration_min_minutes;
|
|
238
|
+
let effMax = meta.estimated_duration_max_minutes;
|
|
239
|
+
if (overrideEstimate) {
|
|
240
|
+
const mn = Number(estimate.minMinutes), mx = Number(estimate.maxMinutes);
|
|
241
|
+
if (!Number.isInteger(mn) || !Number.isInteger(mx) || mn < 0 || mx < mn || mx < 1)
|
|
242
|
+
return { error: 'estimated_duration must be integer minutes with max >= min and max >= 1 (a real, non-zero estimate)', error_code: 'BAD_ESTIMATE' };
|
|
243
|
+
effMin = mn;
|
|
244
|
+
effMax = mx;
|
|
245
|
+
}
|
|
246
|
+
if (estimate?.budget !== undefined && !AGENT_BUDGETS.includes(estimate.budget))
|
|
247
|
+
return { error: 'invalid estimated_agent_budget', error_code: 'BAD_AGENT_BUDGET' };
|
|
248
|
+
if (estimate?.contextSize !== undefined && !CONTEXT_SIZES.includes(estimate.contextSize))
|
|
249
|
+
return { error: 'invalid estimated_context_size', error_code: 'BAD_CONTEXT_SIZE' };
|
|
250
|
+
const estimateReal = effMin != null && effMax != null && !(effMin === 0 && effMax === 0);
|
|
251
|
+
if (!estimateReal)
|
|
252
|
+
return { error: 'a real effort estimate is required before publishing (the draft still has the 0–0 placeholder); supply estimate {minMinutes, maxMinutes}', error_code: 'DRAFT_ESTIMATE_REQUIRED' };
|
|
253
|
+
// all validation passed → publish atomically: persist any estimate override + flip audience + (if
|
|
254
|
+
// applicable) mark the proposal converted. All-or-nothing.
|
|
181
255
|
db.transaction(() => {
|
|
256
|
+
if (overrideEstimate || estimate?.budget !== undefined || estimate?.contextSize !== undefined) {
|
|
257
|
+
setBuildTaskEstimate(db, taskId, { minMinutes: effMin, maxMinutes: effMax, budget: estimate?.budget, contextSize: estimate?.contextSize });
|
|
258
|
+
}
|
|
182
259
|
setBuildTaskAudience(db, taskId, 'public');
|
|
183
260
|
logTaskEvent(db, taskId, adminId, t.status, t.status, 'published from draft (audience → public)');
|
|
184
261
|
if (proposalToConvert) {
|
|
@@ -189,3 +266,95 @@ export function publishDraftBuildTask(db, taskId, adminId) {
|
|
|
189
266
|
})();
|
|
190
267
|
return { ok: true, task_id: taskId };
|
|
191
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* DISCARD an unpublished internal draft — SOFT-delete (status='discarded'); the link row is RETAINED for
|
|
271
|
+
* provenance (retroactive reward / anchor-side traceability / dispute). Discarding frees the proposal's draft
|
|
272
|
+
* slot (createDraftFromProposal counts only non-discarded links), so a fresh draft can be created.
|
|
273
|
+
*
|
|
274
|
+
* Fail-closed: ONLY an internal, unpublished, unclaimed draft may be discarded. A published draft (audience
|
|
275
|
+
* flipped to public), a claimed task, or an already-converted source proposal is REFUSED — discard never
|
|
276
|
+
* touches anything that's live on the board or already accepted. Scope is discard only (NOT a generic edit).
|
|
277
|
+
*/
|
|
278
|
+
export function discardDraft(db, taskId, adminId) {
|
|
279
|
+
const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
|
|
280
|
+
if (!link)
|
|
281
|
+
return { error: 'no draft link for this task', error_code: 'NOT_FOUND' };
|
|
282
|
+
if (link.status === 'discarded')
|
|
283
|
+
return { ok: true, task_id: taskId, already_discarded: true }; // idempotent
|
|
284
|
+
// fail-closed guards — only an internal, unpublished, unclaimed draft of a non-converted proposal
|
|
285
|
+
const meta = getBuildTaskAgentMetadata(db, taskId);
|
|
286
|
+
if (!meta)
|
|
287
|
+
return { error: 'task has no draft metadata', error_code: 'NOT_A_DRAFT' };
|
|
288
|
+
if (meta.audience !== 'internal')
|
|
289
|
+
return { error: 'task is published (audience is not internal) — cannot discard', error_code: 'ALREADY_PUBLISHED' };
|
|
290
|
+
const t = db.prepare('SELECT status, claimer_id FROM build_tasks WHERE id = ?').get(taskId);
|
|
291
|
+
if (!t)
|
|
292
|
+
return { error: 'task not found', error_code: 'NOT_FOUND' };
|
|
293
|
+
if (t.claimer_id)
|
|
294
|
+
return { error: 'task is claimed — cannot discard', error_code: 'DRAFT_CLAIMED' };
|
|
295
|
+
const prop = db.prepare('SELECT status FROM task_proposals WHERE id = ?').get(link.proposal_id);
|
|
296
|
+
if (prop && prop.status === 'converted')
|
|
297
|
+
return { error: 'source proposal already converted — cannot discard', error_code: 'ALREADY_CONVERTED' };
|
|
298
|
+
db.prepare("UPDATE task_proposal_draft_links SET status = 'discarded', discarded_by = ?, discarded_at = datetime('now') WHERE task_id = ?").run(adminId, taskId);
|
|
299
|
+
return { ok: true, task_id: taskId };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Full stored body of an UNPUBLISHED internal draft — for pre-publish PREVIEW so a maintainer publishes
|
|
303
|
+
* against the exact content that will go live (not a blind button). Returns null unless the task is an
|
|
304
|
+
* internal-audience draft (getBuildTaskWithAgentMetadata's member/public scopes deliberately hide internal,
|
|
305
|
+
* so this admin-only read exists). Read-only — does not change publish behavior.
|
|
306
|
+
*/
|
|
307
|
+
export function getDraftBuildTaskDetail(db, taskId) {
|
|
308
|
+
const meta = getBuildTaskAgentMetadata(db, taskId);
|
|
309
|
+
if (!meta || meta.audience !== 'internal')
|
|
310
|
+
return null; // only unpublished internal drafts are previewable here
|
|
311
|
+
const t = db.prepare('SELECT id, title, area, description, rfc_ref, status, created_by, created_at FROM build_tasks WHERE id = ?').get(taskId);
|
|
312
|
+
if (!t)
|
|
313
|
+
return null;
|
|
314
|
+
const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
|
|
315
|
+
return {
|
|
316
|
+
...t,
|
|
317
|
+
source_proposal_id: link?.proposal_id ?? null,
|
|
318
|
+
draft_link_status: link?.status ?? null,
|
|
319
|
+
agent_metadata: meta, // full body: allowed/forbidden paths, prohibited_actions, acceptance_criteria, verification_commands, deliverables, definition_of_done, expected_results, risk_level, auto_claimable, …
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Recovery: WITHDRAW a published task that was converted from a proposal — pull it off the public board and
|
|
324
|
+
* REOPEN the source proposal so a corrected draft can be built. Fail-closed: only an UNCLAIMED, open,
|
|
325
|
+
* published task (audience='public') may be withdrawn (a claimed / in_review / done task is refused).
|
|
326
|
+
*
|
|
327
|
+
* Soft-delete semantics (provenance retained): the build_task is set status='abandoned' (off the board, row
|
|
328
|
+
* kept), the draft link is marked 'discarded' (kept, frees the slot), and the proposal is un-converted
|
|
329
|
+
* (status='new', converted_ref cleared) so createDraftFromProposal works again. Scope = recovery only.
|
|
330
|
+
*/
|
|
331
|
+
export function withdrawPublishedTask(db, taskId, adminId) {
|
|
332
|
+
releaseExpiredClaims(db); // an expired claim is effectively unclaimed
|
|
333
|
+
const meta = getBuildTaskAgentMetadata(db, taskId);
|
|
334
|
+
if (!meta)
|
|
335
|
+
return { error: 'task not found', error_code: 'NOT_FOUND' };
|
|
336
|
+
if (meta.audience !== 'public')
|
|
337
|
+
return { error: 'task is not published (use discard for an internal draft)', error_code: 'NOT_PUBLISHED' };
|
|
338
|
+
const t = db.prepare('SELECT status, claimer_id FROM build_tasks WHERE id = ?').get(taskId);
|
|
339
|
+
if (!t)
|
|
340
|
+
return { error: 'task not found', error_code: 'NOT_FOUND' };
|
|
341
|
+
if (t.claimer_id || t.status !== 'open')
|
|
342
|
+
return { error: `task is ${t.status}${t.claimer_id ? ' (claimed)' : ''} — only an UNCLAIMED open task can be withdrawn`, error_code: 'TASK_CLAIMED' };
|
|
343
|
+
const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
|
|
344
|
+
let reopened = null;
|
|
345
|
+
db.transaction(() => {
|
|
346
|
+
db.prepare("UPDATE build_tasks SET status = 'abandoned', updated_at = datetime('now') WHERE id = ?").run(taskId);
|
|
347
|
+
logTaskEvent(db, taskId, adminId, t.status, 'abandoned', 'withdrawn by admin — published task pulled off the board (recovery)');
|
|
348
|
+
if (link) {
|
|
349
|
+
if (link.status !== 'discarded')
|
|
350
|
+
db.prepare("UPDATE task_proposal_draft_links SET status = 'discarded', discarded_by = ?, discarded_at = datetime('now') WHERE task_id = ?").run(adminId, taskId);
|
|
351
|
+
const prop = db.prepare('SELECT status, converted_ref FROM task_proposals WHERE id = ?').get(link.proposal_id);
|
|
352
|
+
if (prop && prop.status === 'converted' && prop.converted_ref === taskId) {
|
|
353
|
+
db.prepare("UPDATE task_proposals SET status = 'new', converted_ref = NULL, reviewer_id = NULL, review_note = ?, updated_at = datetime('now') WHERE id = ?")
|
|
354
|
+
.run(`reopened: published task ${taskId} withdrawn by admin`, link.proposal_id);
|
|
355
|
+
reopened = link.proposal_id;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
})();
|
|
359
|
+
return { ok: true, task_id: taskId, reopened_proposal_id: reopened };
|
|
360
|
+
}
|
|
@@ -15,6 +15,7 @@ const CREATE_TABLE = `
|
|
|
15
15
|
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','needs_info','rejected','converted')),
|
|
16
16
|
reviewer_id TEXT,
|
|
17
17
|
review_note TEXT CHECK (review_note IS NULL OR length(review_note) <= 2000),
|
|
18
|
+
public_reply TEXT CHECK (public_reply IS NULL OR length(public_reply) <= 2000),
|
|
18
19
|
converted_ref TEXT CHECK (converted_ref IS NULL OR length(converted_ref) <= 500),
|
|
19
20
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
21
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
@@ -24,6 +25,14 @@ const CREATE_INDEX = `CREATE INDEX IF NOT EXISTS idx_task_proposals_status ON ta
|
|
|
24
25
|
export function initTaskProposalSchema(db) {
|
|
25
26
|
db.exec(CREATE_TABLE);
|
|
26
27
|
db.exec(CREATE_INDEX);
|
|
28
|
+
// Additive migration for pre-existing DBs (ALTER after CREATE; fresh DBs already have the column via CREATE_TABLE).
|
|
29
|
+
// public_reply = the proposer-facing reply (distinct from the admin-internal review_note).
|
|
30
|
+
try {
|
|
31
|
+
const cols = db.prepare('PRAGMA table_info(task_proposals)').all();
|
|
32
|
+
if (!cols.some(c => c.name === 'public_reply'))
|
|
33
|
+
db.exec('ALTER TABLE task_proposals ADD COLUMN public_reply TEXT');
|
|
34
|
+
}
|
|
35
|
+
catch { /* best-effort additive migration */ }
|
|
27
36
|
}
|
|
28
37
|
/** Validate a public submission — fail-closed: bad/oversized fields are rejected, never silently truncated. */
|
|
29
38
|
export function validateProposalInput(body) {
|
|
@@ -102,9 +111,21 @@ export function listTaskProposals(db, filter = {}) {
|
|
|
102
111
|
params.push(filter.status);
|
|
103
112
|
}
|
|
104
113
|
return db.prepare(`SELECT id, title, summary, suggested_area, expected_outcome, source_ref, proposer_account_id,
|
|
105
|
-
proposer_github_login, status, reviewer_id, review_note, converted_ref, created_at, updated_at FROM task_proposals
|
|
114
|
+
proposer_github_login, status, reviewer_id, review_note, public_reply, converted_ref, created_at, updated_at FROM task_proposals
|
|
106
115
|
${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY created_at DESC LIMIT 200`).all(...params);
|
|
107
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Proposer-facing read: returns ONLY the caller's own proposals, and ONLY proposer-safe fields.
|
|
119
|
+
* Deliberately excludes the admin-internal `review_note`, `reviewer_id`, and never returns other users' rows
|
|
120
|
+
* (least-privilege; the proposer sees status + the proposer-facing `public_reply` + converted_ref).
|
|
121
|
+
*/
|
|
122
|
+
export function listMyProposals(db, accountId) {
|
|
123
|
+
if (!accountId)
|
|
124
|
+
return [];
|
|
125
|
+
return db.prepare(`SELECT id, title, summary, suggested_area, expected_outcome, source_ref, status,
|
|
126
|
+
public_reply, converted_ref, created_at, updated_at FROM task_proposals
|
|
127
|
+
WHERE proposer_account_id = ? ORDER BY created_at DESC LIMIT 100`).all(accountId);
|
|
128
|
+
}
|
|
108
129
|
/**
|
|
109
130
|
* Review a proposal: needs_info / rejected / converted. NO build_task is created here (conversion is a
|
|
110
131
|
* later manual maintainer step — deferred). Terminal states (rejected/converted) cannot be re-reviewed.
|
|
@@ -112,18 +133,22 @@ export function listTaskProposals(db, filter = {}) {
|
|
|
112
133
|
* product decision — the non-code contribution evidence chain { proposer → reviewer → converted_ref }.
|
|
113
134
|
* Recording only; no reward/score is computed.
|
|
114
135
|
*/
|
|
115
|
-
export function reviewTaskProposal(db, id, reviewerId, status, note, convertedRef) {
|
|
136
|
+
export function reviewTaskProposal(db, id, reviewerId, status, note, convertedRef, publicReply) {
|
|
116
137
|
if (!REVIEW_TARGETS.includes(status))
|
|
117
138
|
return { error: 'status must be needs_info | rejected | converted', code: 'BAD_STATUS' };
|
|
118
139
|
if (convertedRef != null && (typeof convertedRef !== 'string' || convertedRef.length > CONVERTED_REF_MAX))
|
|
119
140
|
return { error: 'converted_ref invalid/too long', code: 'CONVERTED_REF_TOO_LONG' };
|
|
141
|
+
if (publicReply != null && typeof publicReply !== 'string')
|
|
142
|
+
return { error: 'public_reply must be a string', code: 'PUBLIC_REPLY_INVALID' };
|
|
120
143
|
const cur = db.prepare('SELECT status FROM task_proposals WHERE id = ?').get(id);
|
|
121
144
|
if (!cur)
|
|
122
145
|
return { error: 'proposal not found', code: 'NOT_FOUND' };
|
|
123
146
|
if (cur.status === 'rejected' || cur.status === 'converted')
|
|
124
147
|
return { error: `proposal already ${cur.status}`, code: 'ALREADY_TERMINAL' };
|
|
125
148
|
const ref = status === 'converted' && typeof convertedRef === 'string' && convertedRef.trim() ? convertedRef.trim() : null;
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
// public_reply: update only when provided (COALESCE keeps a prior reply if this review omits it).
|
|
150
|
+
const reply = publicReply != null ? String(publicReply).slice(0, NOTE_MAX) : null;
|
|
151
|
+
db.prepare(`UPDATE task_proposals SET status = ?, reviewer_id = ?, review_note = ?, public_reply = COALESCE(?, public_reply), converted_ref = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
152
|
+
.run(status, reviewerId, note ? String(note).slice(0, NOTE_MAX) : null, reply, ref, id);
|
|
128
153
|
return { id, status, converted_ref: ref };
|
|
129
154
|
}
|
package/dist/ledger.js
CHANGED
|
@@ -42,7 +42,7 @@ export function debitStakeThenBalance(db, userId, amountU) {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* 通用基金/池表的【绝对值】入账(整数 base-units):读当前→加 delta→写 toDecimal。
|
|
45
|
-
* 用于 global_fund /
|
|
45
|
+
* 用于 global_fund / protocol_reserve_pool / commission_reserve / charity_fund 等单行池表。
|
|
46
46
|
* table / 列名 / whereClause 均为【代码字面量】(非用户输入)→ 无 SQL 注入面。
|
|
47
47
|
* 行不存在 → UPDATE 影响 0 行(与历史相对更新一致)。
|
|
48
48
|
*/
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { generateId } from '../layer0-foundation/L0-1-database/schema.js';
|
|
2
|
+
/** Default context for an action logged without explicit accountability — NOT contribution-eligible. */
|
|
3
|
+
function normalizeContext(ctx) {
|
|
4
|
+
return {
|
|
5
|
+
actor_type: ctx?.actorType ?? 'unknown_agent',
|
|
6
|
+
actor_ref: ctx?.actorRef ?? null,
|
|
7
|
+
agent_mode: ctx?.agentMode ?? 'unknown_agent',
|
|
8
|
+
human_authorization_id: ctx?.humanAuthorizationId ?? null,
|
|
9
|
+
mandate_id: ctx?.mandateId ?? null,
|
|
10
|
+
approval_kind: ctx?.approvalKind ?? null,
|
|
11
|
+
conflict_disclosure: ctx?.conflictDisclosure ?? 'unknown',
|
|
12
|
+
provenance: ctx?.provenance ?? null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Write one admin_audit_log row (with `detail._ctx` accountability context) and return its id. The id
|
|
17
|
+
* is what the coordination ingestion engine references as `admin_audit_log_id`.
|
|
18
|
+
*/
|
|
19
|
+
export function logAdminAction(db, input) {
|
|
20
|
+
const id = generateId('audit');
|
|
21
|
+
const detail = JSON.stringify({ ...(input.detail ?? {}), _ctx: normalizeContext(input.context) });
|
|
22
|
+
db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
|
|
23
|
+
.run(id, input.adminId, input.action, input.targetType ?? null, input.targetId ?? null, detail);
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
/** Read back the accountability context of an audit row. Absent `_ctx` → unknown / not eligible. */
|
|
27
|
+
export function readAdminActionContext(detailJson) {
|
|
28
|
+
if (!detailJson)
|
|
29
|
+
return normalizeContext();
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(detailJson);
|
|
32
|
+
const ctx = parsed && typeof parsed === 'object' ? parsed._ctx : undefined;
|
|
33
|
+
return ctx && typeof ctx === 'object' ? ctx : normalizeContext();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return normalizeContext();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** 默认值 === 抽取前的硬编码字面量。修改这里 = 修改全协议默认行为。 */
|
|
2
|
+
export const DEFAULT_ANTI_ABUSE_THRESHOLDS = {
|
|
3
|
+
trustDisputePenalty: 10,
|
|
4
|
+
trustSybilFreeThreshold: 3,
|
|
5
|
+
trustSybilPenalty: 5,
|
|
6
|
+
trustCrossPenalty: 3,
|
|
7
|
+
trustRatelimitPenalty: 2,
|
|
8
|
+
trustLevelTrusted: 20,
|
|
9
|
+
trustLevelQuality: 50,
|
|
10
|
+
trustLevelLegend: 80,
|
|
11
|
+
strikeWarnWindowDays: 7,
|
|
12
|
+
strikeWarnEscalateCount: 1,
|
|
13
|
+
strikeSuspendWindowDays: 30,
|
|
14
|
+
strikeSuspendEscalateCount: 2,
|
|
15
|
+
strikeWarnExpiryHours: 24,
|
|
16
|
+
strikeSuspendExpiryDays: 7,
|
|
17
|
+
outlierWindowDays: 180,
|
|
18
|
+
outlierSuspendCount: 3,
|
|
19
|
+
outlierRevokeCount: 5,
|
|
20
|
+
outlierSuspendDays: 30,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* protocol_params 注册定义(spread 进 server.ts 的 DEFAULT_PARAMS)。
|
|
24
|
+
* value 必须与 DEFAULT_ANTI_ABUSE_THRESHOLDS 完全一致(测试强制校验)。
|
|
25
|
+
*/
|
|
26
|
+
export const ANTI_ABUSE_PARAMS = [
|
|
27
|
+
// P1-2 agent 信任公式(公开框架 + 治理可调系数)。等级阈值 cap=1000 给治理收紧空间。
|
|
28
|
+
{ key: 'agent_trust_dispute_penalty', value: '10', type: 'number', description: 'agent 信任分:每次败诉 dispute(refund/partial)扣分(#420 P1-2)', category: 'security', min: 0, max: 100 },
|
|
29
|
+
{ key: 'agent_trust_sybil_free_threshold', value: '3', type: 'number', description: 'agent 信任分:同 IP 注册账户数 ≤ 此值不计 sybil 罚分(含本账户)(#420 P1-2)', category: 'security', min: 0, max: 100 },
|
|
30
|
+
{ key: 'agent_trust_sybil_penalty', value: '5', type: 'number', description: 'agent 信任分:超出 free 阈值后每多 1 个同 IP 账户扣分(#420 P1-2)', category: 'security', min: 0, max: 100 },
|
|
31
|
+
{ key: 'agent_trust_cross_penalty', value: '3', type: 'number', description: 'agent 信任分:每次放置同支审计(commission cross)命中扣分(#420 P1-2)', category: 'security', min: 0, max: 100 },
|
|
32
|
+
{ key: 'agent_trust_ratelimit_penalty', value: '2', type: 'number', description: 'agent 信任分:30d 内每次 429 限速命中扣分(#420 P1-2)', category: 'security', min: 0, max: 100 },
|
|
33
|
+
{ key: 'agent_trust_level_trusted', value: '20', type: 'number', description: 'agent 信任分 ≥ 此值 → trusted 级(#420 P1-2;等级 gate 速率上限)', category: 'security', min: 0, max: 1000 },
|
|
34
|
+
{ key: 'agent_trust_level_quality', value: '50', type: 'number', description: 'agent 信任分 ≥ 此值 → quality 级(#420 P1-2)', category: 'security', min: 0, max: 1000 },
|
|
35
|
+
{ key: 'agent_trust_level_legend', value: '80', type: 'number', description: 'agent 信任分 ≥ 此值 → legend 级(#420 P1-2)', category: 'security', min: 0, max: 1000 },
|
|
36
|
+
// P1-4 agent strike 升级阶梯(公开 consequence-transparency,见 negative-space.ts;数值治理可调)
|
|
37
|
+
{ key: 'agent_strike_warn_window_days', value: '7', type: 'number', description: 'agent strike:统计 warning 的回看窗口(天)(#420 P1-4)', category: 'security', min: 1, max: 365 },
|
|
38
|
+
{ key: 'agent_strike_warn_escalate_count', value: '1', type: 'number', description: 'agent strike:窗口内已有 ≥N 次 warning 时本次 warning 升级为 suspend_7d(默认 1=累计第 2 次升级)(#420 P1-4)', category: 'security', min: 1, max: 100 },
|
|
39
|
+
{ key: 'agent_strike_suspend_window_days', value: '30', type: 'number', description: 'agent strike:统计 suspend_7d 的回看窗口(天)(#420 P1-4)', category: 'security', min: 1, max: 365 },
|
|
40
|
+
{ key: 'agent_strike_suspend_escalate_count', value: '2', type: 'number', description: 'agent strike:窗口内已有 ≥N 次 suspend_7d 时升级为 permanent(默认 2=累计第 3 次升级)(#420 P1-4)', category: 'security', min: 1, max: 100 },
|
|
41
|
+
{ key: 'agent_strike_warn_expiry_hours', value: '24', type: 'number', description: 'agent strike:warning 自动过期小时数(#420 P1-4)', category: 'security', min: 1, max: 720 },
|
|
42
|
+
{ key: 'agent_strike_suspend_expiry_days', value: '7', type: 'number', description: 'agent strike:suspend_7d 自动过期天数(#420 P1-4)', category: 'security', min: 1, max: 365 },
|
|
43
|
+
// P1-3 verifier outlier 处罚阈值(少数票 was_majority=0 即时处罚;非 playbook §6.2 confirmed_wrong cron)
|
|
44
|
+
{ key: 'verifier_outlier_window_days', value: '180', type: 'number', description: 'verifier outlier:统计少数票(was_majority=0)的回看窗口(天)(#420 P1-3)', category: 'governance', min: 1, max: 730 },
|
|
45
|
+
{ key: 'verifier_outlier_suspend_count', value: '3', type: 'number', description: 'verifier outlier:窗口内 ≥N 次 → 暂停资格(#420 P1-3)', category: 'governance', min: 1, max: 100 },
|
|
46
|
+
{ key: 'verifier_outlier_revoke_count', value: '5', type: 'number', description: 'verifier outlier:窗口内 ≥N 次 → 永久撤销资格(#420 P1-3)', category: 'governance', min: 1, max: 100 },
|
|
47
|
+
{ key: 'verifier_outlier_suspend_days', value: '30', type: 'number', description: 'verifier outlier:暂停时长(天)(#420 P1-3)', category: 'governance', min: 1, max: 365 },
|
|
48
|
+
];
|
|
49
|
+
// ── 同步读取器(与生产热路径同步语义:better-sqlite3 prepared get)──
|
|
50
|
+
function num(db, key, fallback) {
|
|
51
|
+
try {
|
|
52
|
+
const r = db.prepare('SELECT value FROM protocol_params WHERE key = ?').get(key);
|
|
53
|
+
if (!r)
|
|
54
|
+
return fallback;
|
|
55
|
+
const n = Number(r.value);
|
|
56
|
+
return Number.isFinite(n) ? n : fallback;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** 用于会被插入 SQL 字符串的窗口天数:强制非负整数(injection-safe + 语义正确)。 */
|
|
63
|
+
function intNum(db, key, fallback) {
|
|
64
|
+
const n = Math.round(num(db, key, fallback));
|
|
65
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
66
|
+
}
|
|
67
|
+
/** 从 protocol_params 读取全部反滥用阈值;缺行/坏值回落默认(= 当前生产行为)。 */
|
|
68
|
+
export function readAntiAbuseThresholds(db) {
|
|
69
|
+
const d = DEFAULT_ANTI_ABUSE_THRESHOLDS;
|
|
70
|
+
return {
|
|
71
|
+
trustDisputePenalty: num(db, 'agent_trust_dispute_penalty', d.trustDisputePenalty),
|
|
72
|
+
trustSybilFreeThreshold: num(db, 'agent_trust_sybil_free_threshold', d.trustSybilFreeThreshold),
|
|
73
|
+
trustSybilPenalty: num(db, 'agent_trust_sybil_penalty', d.trustSybilPenalty),
|
|
74
|
+
trustCrossPenalty: num(db, 'agent_trust_cross_penalty', d.trustCrossPenalty),
|
|
75
|
+
trustRatelimitPenalty: num(db, 'agent_trust_ratelimit_penalty', d.trustRatelimitPenalty),
|
|
76
|
+
trustLevelTrusted: num(db, 'agent_trust_level_trusted', d.trustLevelTrusted),
|
|
77
|
+
trustLevelQuality: num(db, 'agent_trust_level_quality', d.trustLevelQuality),
|
|
78
|
+
trustLevelLegend: num(db, 'agent_trust_level_legend', d.trustLevelLegend),
|
|
79
|
+
strikeWarnWindowDays: intNum(db, 'agent_strike_warn_window_days', d.strikeWarnWindowDays),
|
|
80
|
+
strikeWarnEscalateCount: num(db, 'agent_strike_warn_escalate_count', d.strikeWarnEscalateCount),
|
|
81
|
+
strikeSuspendWindowDays: intNum(db, 'agent_strike_suspend_window_days', d.strikeSuspendWindowDays),
|
|
82
|
+
strikeSuspendEscalateCount: num(db, 'agent_strike_suspend_escalate_count', d.strikeSuspendEscalateCount),
|
|
83
|
+
strikeWarnExpiryHours: num(db, 'agent_strike_warn_expiry_hours', d.strikeWarnExpiryHours),
|
|
84
|
+
strikeSuspendExpiryDays: num(db, 'agent_strike_suspend_expiry_days', d.strikeSuspendExpiryDays),
|
|
85
|
+
outlierWindowDays: intNum(db, 'verifier_outlier_window_days', d.outlierWindowDays),
|
|
86
|
+
outlierSuspendCount: num(db, 'verifier_outlier_suspend_count', d.outlierSuspendCount),
|
|
87
|
+
outlierRevokeCount: num(db, 'verifier_outlier_revoke_count', d.outlierRevokeCount),
|
|
88
|
+
outlierSuspendDays: num(db, 'verifier_outlier_suspend_days', d.outlierSuspendDays),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// ── 纯决策函数(生产 + 测试共用;无副作用,不读 db)──
|
|
92
|
+
/** P1-2:trust 分 → 等级。镜像原 server.ts:3818-3821 的 ≥ 级联。 */
|
|
93
|
+
export function agentTrustLevel(score, t) {
|
|
94
|
+
if (score >= t.trustLevelLegend)
|
|
95
|
+
return 'legend';
|
|
96
|
+
if (score >= t.trustLevelQuality)
|
|
97
|
+
return 'quality';
|
|
98
|
+
if (score >= t.trustLevelTrusted)
|
|
99
|
+
return 'trusted';
|
|
100
|
+
return 'new';
|
|
101
|
+
}
|
|
102
|
+
/** P1-2:sybil 罚分。镜像原 `sybilSize > free ? -(sybilSize-free)*pen : 0`。返回非正数。 */
|
|
103
|
+
export function agentSybilPenalty(sybilSize, t) {
|
|
104
|
+
return sybilSize > t.trustSybilFreeThreshold ? -(sybilSize - t.trustSybilFreeThreshold) * t.trustSybilPenalty : 0;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* P1-4:strike 升级判定。镜像原 server.ts:4493-4502。
|
|
108
|
+
* priorWarnings = 窗口内已有未过期 warning 数;priorSuspends = 窗口内已有 suspend_7d 数。
|
|
109
|
+
*/
|
|
110
|
+
export function agentStrikeSeverity(initial, priorWarnings, priorSuspends, t) {
|
|
111
|
+
let severity = initial;
|
|
112
|
+
let escalated = false;
|
|
113
|
+
if (initial === 'warning' && priorWarnings >= t.strikeWarnEscalateCount) {
|
|
114
|
+
severity = 'suspend_7d';
|
|
115
|
+
escalated = true;
|
|
116
|
+
}
|
|
117
|
+
if (initial === 'suspend_7d' || severity === 'suspend_7d') {
|
|
118
|
+
if (priorSuspends >= t.strikeSuspendEscalateCount) {
|
|
119
|
+
severity = 'permanent';
|
|
120
|
+
escalated = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { severity, escalated };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* P1-3:verifier outlier 累计数 → 处罚档位(不含 existing/dup 守卫,由调用方各自施加)。
|
|
127
|
+
* 镜像原 revoke-优先、suspend-其次 的阈值比较。
|
|
128
|
+
*/
|
|
129
|
+
export function verifierOutlierBand(count, t) {
|
|
130
|
+
if (count >= t.outlierRevokeCount)
|
|
131
|
+
return 'revoke';
|
|
132
|
+
if (count >= t.outlierSuspendCount)
|
|
133
|
+
return 'suspend';
|
|
134
|
+
return 'none';
|
|
135
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export const CF_ORIGIN_HEADER = 'x-cf-origin-secret';
|
|
3
|
+
function safeEqual(a, b) {
|
|
4
|
+
const ab = Buffer.from(a);
|
|
5
|
+
const bb = Buffer.from(b);
|
|
6
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
7
|
+
}
|
|
8
|
+
export function createCfOriginGuard(env = process.env) {
|
|
9
|
+
const mode = (env.CF_ORIGIN_GUARD_MODE || 'off').toLowerCase();
|
|
10
|
+
const secret = env.CF_ORIGIN_SHARED_SECRET || '';
|
|
11
|
+
const exempt = new Set((env.CF_ORIGIN_GUARD_EXEMPT || '/api/health,/healthz')
|
|
12
|
+
.split(',').map(s => s.trim()).filter(Boolean));
|
|
13
|
+
return function cfOriginGuard(req, res, next) {
|
|
14
|
+
if (mode === 'off')
|
|
15
|
+
return next();
|
|
16
|
+
if (exempt.has(req.path))
|
|
17
|
+
return next();
|
|
18
|
+
if (!secret) {
|
|
19
|
+
if (mode === 'enforce') {
|
|
20
|
+
console.error('[cf-origin-guard] enforce set but CF_ORIGIN_SHARED_SECRET is empty — failing OPEN (no block)');
|
|
21
|
+
}
|
|
22
|
+
return next();
|
|
23
|
+
}
|
|
24
|
+
const got = req.get(CF_ORIGIN_HEADER) || '';
|
|
25
|
+
if (got && safeEqual(got, secret))
|
|
26
|
+
return next(); // arrived via Cloudflare
|
|
27
|
+
if (mode === 'observe') {
|
|
28
|
+
console.warn(`[cf-origin-guard] observe: would block direct-origin ${req.method} ${req.path} ip=${req.ip}`);
|
|
29
|
+
return next();
|
|
30
|
+
}
|
|
31
|
+
res.status(403).json({ error: 'direct origin access not allowed; use the public endpoint', error_code: 'CF_ORIGIN_ONLY' });
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -35,6 +35,7 @@ export const CONTRACT_CHANGES = [
|
|
|
35
35
|
{ contract_version: 2, date: '2026-06-06', surface: 'entity', kind: 'added', summary: '§① entity dictionary gains product + dispute entities (conservative public fields; dispute = redacted dispute_cases) + goal_index pointer. Additive — existing order entity unchanged; agents may ignore.' },
|
|
36
36
|
{ contract_version: 3, date: '2026-06-09', surface: 'entity', kind: 'changed', summary: '§① order lifecycle: corrected declined_nofault state meaning text — it is NOT terminal (transitions declined_nofault→completed on settlement). Dropped the contradictory "(terminal)" label that conflicted with the auto-derived terminal:false. Semantics/state-machine unchanged; text-only clarification for agents reading the entity dictionary.' },
|
|
37
37
|
{ contract_version: 4, date: '2026-06-09', surface: 'capability', kind: 'changed', summary: '§② capability matrix: POST /api/reviews/:type/:id/claim now requires the new "review_claim" action scope instead of being SAFE (unscoped). The review-claim path locks a 5 WAZ stake (escrow), so it belongs under default-deny accountability like other value writes. GET reviews endpoints stay open.', migration: 'A declared agent that calls review claim must add the "review_claim" scope to its api_key (or hold a Passkey, or declare "*"). Passkey-bound humans and "*" agents are unaffected; GET reviews unchanged.' },
|
|
38
|
+
{ contract_version: 5, date: '2026-06-24', surface: 'integration', kind: 'added', summary: '§④ integration-contract entry (/.well-known/webaz-integration.json) gains an agent_quickstart block: a 60-second stranger-agent cold-start with discrete, machine-parseable fields (canonical_start_url, public_readonly_entrypoints, anonymous_allowed_actions, authenticated_required_actions, safe_next_actions, proposal_flow, contribution_boundary). Additive — no existing field changed; agents may ignore. NB: this surface is NOT covered by the §②/§① fingerprint, so it is registered here by hand.' },
|
|
38
39
|
];
|
|
39
40
|
export function buildChangeFeed() {
|
|
40
41
|
return {
|
|
@@ -77,14 +77,14 @@ export const ONBOARDING_CASES = [
|
|
|
77
77
|
],
|
|
78
78
|
decision_options: [
|
|
79
79
|
{ key: 'release_seller', text_zh: 'release_seller', text_en: 'release_seller' },
|
|
80
|
-
{ key: 'refund_buyer', text_zh: 'refund_buyer + 物流 stake 优先赔买家 +
|
|
80
|
+
{ key: 'refund_buyer', text_zh: 'refund_buyer + 物流 stake 优先赔买家 + protocol_reserve_pool 兜底', text_en: 'refund_buyer + logistics stake to buyer first + protocol_reserve_pool covers shortfall' },
|
|
81
81
|
{ key: 'partial_refund', text_zh: 'partial_refund 50/50', text_en: 'partial_refund 50/50' },
|
|
82
82
|
{ key: 'liability_split', text_zh: 'liability_split(卖家一半 / 物流方一半)', text_en: 'liability_split (half seller / half logistics)' },
|
|
83
83
|
],
|
|
84
84
|
expected_verdict: 'refund_buyer',
|
|
85
85
|
key_principles: [
|
|
86
86
|
'ARBITRATION-PLAYBOOK Case 2 物流卡顿 — 卖家无过错应保护',
|
|
87
|
-
'
|
|
87
|
+
'protocol_reserve_pool 来源 = ECONOMIC §3 ④a + 失效活动罚没',
|
|
88
88
|
'资金流向:物流 stake 优先赔买家(不入 pool),pool 仅兜底差额',
|
|
89
89
|
'物流方 debt_to_protocol 累计 → > 1000 WAZ 角色暂停',
|
|
90
90
|
],
|
|
@@ -128,7 +128,7 @@ export const ONBOARDING_QUIZ = [
|
|
|
128
128
|
question_en: 'Case 2 logistics stuck: seller has shipping doc, buyer never received, logistics gone 45 days. Arbitrator should:',
|
|
129
129
|
options: [
|
|
130
130
|
{ key: 'A', text_zh: 'release_seller(卖家无过错)', text_en: 'release_seller (seller has no fault)' },
|
|
131
|
-
{ key: 'B', text_zh: 'refund_buyer + 物流 stake **优先赔买家**(不足部分由
|
|
131
|
+
{ key: 'B', text_zh: 'refund_buyer + 物流 stake **优先赔买家**(不足部分由 protocol_reserve_pool 兜底差额)', text_en: 'refund_buyer + logistics stake **goes to buyer first** (shortfall covered by protocol_reserve_pool)' },
|
|
132
132
|
{ key: 'C', text_zh: 'partial_refund 50/50', text_en: 'partial_refund 50/50' },
|
|
133
133
|
{ key: 'D', text_zh: 'liability_split,卖家一半物流方一半', text_en: 'liability_split, half seller half logistics' },
|
|
134
134
|
],
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* - 守恒是硬不变量:所有罚没【再分配,绝不增发】(settleFault)。
|
|
10
10
|
* - 诚实 status:已上线角色标 live;通用第三方承保方(无真实需求)标 scaffolded → 自有 RFC + enters-core 门控,不过早造接口。
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* 公平三原则锚:公开透明 / 谁责任谁承担 / 无责方零成本。
|
|
13
13
|
*/
|
|
14
14
|
import { SOFTWARE_VERSION, CONTRACT_VERSION } from '../version.js';
|
|
15
|
-
const GH = 'https://github.com/
|
|
15
|
+
const GH = 'https://github.com/webaz-protocol/webaz/blob/main';
|
|
16
16
|
const BASE = 'https://webaz.xyz';
|
|
17
17
|
export function buildEconomicParticipation(getParam) {
|
|
18
18
|
const num = (k, f) => getParam(k, f);
|